From b1d20d38f9fcdd0f8aaecfa7c315161e047352ba Mon Sep 17 00:00:00 2001 From: Alix-007 <267018309+Alix-007@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:53:40 +0800 Subject: [PATCH] feat(skills): add read_skill for compact mode --- src/agent/loop_.rs | 18 ++++ src/agent/prompt.rs | 1 + src/channels/mod.rs | 10 +++ src/onboard/wizard.rs | 2 +- src/skills/mod.rs | 13 ++- src/tools/mod.rs | 81 +++++++++++++++++ src/tools/read_skill.rs | 187 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 310 insertions(+), 2 deletions(-) create mode 100644 src/tools/read_skill.rs diff --git a/src/agent/loop_.rs b/src/agent/loop_.rs index 5442f05c5..0ce458ed9 100644 --- a/src/agent/loop_.rs +++ b/src/agent/loop_.rs @@ -3369,6 +3369,15 @@ pub async fn run( "Delete a memory entry. Use when: memory is incorrect/stale or explicitly requested for removal. Don't use when: impact is uncertain.", ), ]; + if matches!( + config.skills.prompt_injection_mode, + crate::config::SkillsPromptInjectionMode::Compact + ) { + tool_descs.push(( + "read_skill", + "Load the full source for an available skill by name. Use when: compact mode only shows a summary and you need the complete skill instructions.", + )); + } tool_descs.push(( "cron_add", "Create a cron job. Supports schedule kinds: cron, at, every; and job types: shell or agent.", @@ -4052,6 +4061,15 @@ pub async fn process_message( ("screenshot", "Capture a screenshot."), ("image_info", "Read image metadata."), ]; + if matches!( + config.skills.prompt_injection_mode, + crate::config::SkillsPromptInjectionMode::Compact + ) { + tool_descs.push(( + "read_skill", + "Load the full source for an available skill by name.", + )); + } if config.browser.enabled { tool_descs.push(("browser_open", "Open approved URLs in browser.")); } diff --git a/src/agent/prompt.rs b/src/agent/prompt.rs index eb0291a15..721e9aad2 100644 --- a/src/agent/prompt.rs +++ b/src/agent/prompt.rs @@ -436,6 +436,7 @@ mod tests { assert!(output.contains("")); assert!(output.contains("deploy")); assert!(output.contains("skills/deploy/SKILL.md")); + assert!(output.contains("read_skill(name)")); assert!(!output.contains("Run smoke tests before deploy.")); assert!(!output.contains("")); } diff --git a/src/channels/mod.rs b/src/channels/mod.rs index e34117a08..8339a51c9 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -3977,6 +3977,16 @@ pub async fn start_channels(config: Config) -> Result<()> { ), ]; + if matches!( + config.skills.prompt_injection_mode, + crate::config::SkillsPromptInjectionMode::Compact + ) { + tool_descs.push(( + "read_skill", + "Load the full source for an available skill by name. Use when: compact mode only shows a summary and you need the complete skill instructions.", + )); + } + if config.browser.enabled { tool_descs.push(( "browser_open", diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index dc664a33a..d796c21dc 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -5367,7 +5367,7 @@ async fn scaffold_workspace(workspace_dir: &Path, ctx: &ProjectContext) -> Resul Participate, don't dominate. Respond when mentioned or when you add genuine value.\n\ Stay silent when it's casual banter or someone already answered.\n\n\ ## Tools & Skills\n\n\ - Skills are listed in the system prompt. Use `read` on a skill's SKILL.md for details.\n\ + Skills are listed in the system prompt. Use `read_skill` when available, or `file_read` on a skill file, for full details.\n\ Keep local notes (SSH hosts, device names, etc.) in `TOOLS.md`.\n\n\ ## Crash Recovery\n\n\ - If a run stops unexpectedly, recover context before acting.\n\ diff --git a/src/skills/mod.rs b/src/skills/mod.rs index ff8f902e2..9d1042496 100644 --- a/src/skills/mod.rs +++ b/src/skills/mod.rs @@ -97,6 +97,15 @@ pub fn load_skills_with_config(workspace_dir: &Path, config: &crate::config::Con ) } +/// Load skills using explicit open-skills settings. +pub fn load_skills_with_open_skills_settings( + workspace_dir: &Path, + open_skills_enabled: bool, + open_skills_dir: Option<&str>, +) -> Vec { + load_skills_with_open_skills_config(workspace_dir, Some(open_skills_enabled), open_skills_dir) +} + fn load_skills_with_open_skills_config( workspace_dir: &Path, config_open_skills_enabled: Option, @@ -674,7 +683,8 @@ pub fn skills_to_prompt_with_mode( crate::config::SkillsPromptInjectionMode::Compact => String::from( "## Available Skills\n\n\ Skill summaries are preloaded below to keep context compact.\n\ - Skill instructions are loaded on demand: read the skill file in `location` only when needed.\n\n\ + Skill instructions are loaded on demand: call `read_skill(name)` with the skill's `` when you need the full skill file.\n\ + The `location` field is included for reference.\n\n\ \n", ), }; @@ -1267,6 +1277,7 @@ command = "echo hello" assert!(prompt.contains("test")); assert!(prompt.contains("skills/test/SKILL.md")); assert!(prompt.contains("loaded on demand")); + assert!(prompt.contains("read_skill(name)")); assert!(!prompt.contains("")); assert!(!prompt.contains("Do the thing.")); assert!(!prompt.contains("")); diff --git a/src/tools/mod.rs b/src/tools/mod.rs index d6880aac4..f4ebee32c 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -66,6 +66,7 @@ pub mod pdf_read; pub mod project_intel; pub mod proxy_config; pub mod pushover; +pub mod read_skill; pub mod report_templates; pub mod schedule; pub mod schema; @@ -128,6 +129,7 @@ pub use pdf_read::PdfReadTool; pub use project_intel::ProjectIntelTool; pub use proxy_config::ProxyConfigTool; pub use pushover::PushoverTool; +pub use read_skill::ReadSkillTool; pub use schedule::ScheduleTool; #[allow(unused_imports)] pub use schema::{CleaningStrategy, SchemaCleanr}; @@ -316,6 +318,17 @@ pub fn all_tools_with_runtime( )), ]; + if matches!( + root_config.skills.prompt_injection_mode, + crate::config::SkillsPromptInjectionMode::Compact + ) { + tool_arcs.push(Arc::new(ReadSkillTool::new( + workspace_dir.to_path_buf(), + root_config.skills.open_skills_enabled, + root_config.skills.open_skills_dir.clone(), + ))); + } + if browser_config.enabled { // Add legacy browser_open tool for simple URL opening tool_arcs.push(Arc::new(BrowserOpenTool::new( @@ -972,4 +985,72 @@ mod tests { let names: Vec<&str> = tools.iter().map(|t| t.name()).collect(); assert!(!names.contains(&"delegate")); } + + #[test] + fn all_tools_includes_read_skill_in_compact_mode() { + let tmp = TempDir::new().unwrap(); + let security = Arc::new(SecurityPolicy::default()); + let mem_cfg = MemoryConfig { + backend: "markdown".into(), + ..MemoryConfig::default() + }; + let mem: Arc = + Arc::from(crate::memory::create_memory(&mem_cfg, tmp.path(), None).unwrap()); + + let browser = BrowserConfig::default(); + let http = crate::config::HttpRequestConfig::default(); + let mut cfg = test_config(&tmp); + cfg.skills.prompt_injection_mode = crate::config::SkillsPromptInjectionMode::Compact; + + let (tools, _) = all_tools( + Arc::new(cfg.clone()), + &security, + mem, + None, + None, + &browser, + &http, + &crate::config::WebFetchConfig::default(), + tmp.path(), + &HashMap::new(), + None, + &cfg, + ); + let names: Vec<&str> = tools.iter().map(|t| t.name()).collect(); + assert!(names.contains(&"read_skill")); + } + + #[test] + fn all_tools_excludes_read_skill_in_full_mode() { + let tmp = TempDir::new().unwrap(); + let security = Arc::new(SecurityPolicy::default()); + let mem_cfg = MemoryConfig { + backend: "markdown".into(), + ..MemoryConfig::default() + }; + let mem: Arc = + Arc::from(crate::memory::create_memory(&mem_cfg, tmp.path(), None).unwrap()); + + let browser = BrowserConfig::default(); + let http = crate::config::HttpRequestConfig::default(); + let mut cfg = test_config(&tmp); + cfg.skills.prompt_injection_mode = crate::config::SkillsPromptInjectionMode::Full; + + let (tools, _) = all_tools( + Arc::new(cfg.clone()), + &security, + mem, + None, + None, + &browser, + &http, + &crate::config::WebFetchConfig::default(), + tmp.path(), + &HashMap::new(), + None, + &cfg, + ); + let names: Vec<&str> = tools.iter().map(|t| t.name()).collect(); + assert!(!names.contains(&"read_skill")); + } } diff --git a/src/tools/read_skill.rs b/src/tools/read_skill.rs new file mode 100644 index 000000000..41e7a871b --- /dev/null +++ b/src/tools/read_skill.rs @@ -0,0 +1,187 @@ +use super::traits::{Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::json; +use std::path::PathBuf; + +/// Compact-mode helper for loading a skill's source file on demand. +pub struct ReadSkillTool { + workspace_dir: PathBuf, + open_skills_enabled: bool, + open_skills_dir: Option, +} + +impl ReadSkillTool { + pub fn new( + workspace_dir: PathBuf, + open_skills_enabled: bool, + open_skills_dir: Option, + ) -> Self { + Self { + workspace_dir, + open_skills_enabled, + open_skills_dir, + } + } +} + +#[async_trait] +impl Tool for ReadSkillTool { + fn name(&self) -> &str { + "read_skill" + } + + fn description(&self) -> &str { + "Read the full source file for an available skill by name. Use this in compact skills mode when you need the complete skill instructions without remembering file paths." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The skill name exactly as listed in ." + } + }, + "required": ["name"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let requested = args + .get("name") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| anyhow::anyhow!("Missing 'name' parameter"))?; + + let skills = crate::skills::load_skills_with_open_skills_settings( + &self.workspace_dir, + self.open_skills_enabled, + self.open_skills_dir.as_deref(), + ); + + let Some(skill) = skills + .iter() + .find(|skill| skill.name.eq_ignore_ascii_case(requested)) + else { + let mut names: Vec<&str> = skills.iter().map(|skill| skill.name.as_str()).collect(); + names.sort_unstable(); + let available = if names.is_empty() { + "none".to_string() + } else { + names.join(", ") + }; + + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!( + "Unknown skill '{requested}'. Available skills: {available}" + )), + }); + }; + + let Some(location) = skill.location.as_ref() else { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!( + "Skill '{}' has no readable source location.", + skill.name + )), + }); + }; + + match tokio::fs::read_to_string(location).await { + Ok(output) => Ok(ToolResult { + success: true, + output, + error: None, + }), + Err(err) => Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!( + "Failed to read skill '{}' from {}: {err}", + skill.name, + location.display() + )), + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn make_tool(tmp: &TempDir) -> ReadSkillTool { + ReadSkillTool::new(tmp.path().join("workspace"), false, None) + } + + #[tokio::test] + async fn reads_markdown_skill_by_name() { + let tmp = TempDir::new().unwrap(); + let skill_dir = tmp.path().join("workspace/skills/weather"); + std::fs::create_dir_all(&skill_dir).unwrap(); + std::fs::write( + skill_dir.join("SKILL.md"), + "# Weather\n\nUse this skill for forecast lookups.\n", + ) + .unwrap(); + + let result = make_tool(&tmp) + .execute(json!({ "name": "weather" })) + .await + .unwrap(); + + assert!(result.success); + assert!(result.output.contains("# Weather")); + assert!(result.output.contains("forecast lookups")); + } + + #[tokio::test] + async fn reads_toml_skill_manifest_by_name() { + let tmp = TempDir::new().unwrap(); + let skill_dir = tmp.path().join("workspace/skills/deploy"); + std::fs::create_dir_all(&skill_dir).unwrap(); + std::fs::write( + skill_dir.join("SKILL.toml"), + r#"[skill] +name = "deploy" +description = "Ship safely" +"#, + ) + .unwrap(); + + let result = make_tool(&tmp) + .execute(json!({ "name": "deploy" })) + .await + .unwrap(); + + assert!(result.success); + assert!(result.output.contains("[skill]")); + assert!(result.output.contains("Ship safely")); + } + + #[tokio::test] + async fn unknown_skill_lists_available_names() { + let tmp = TempDir::new().unwrap(); + let skill_dir = tmp.path().join("workspace/skills/weather"); + std::fs::create_dir_all(&skill_dir).unwrap(); + std::fs::write(skill_dir.join("SKILL.md"), "# Weather\n").unwrap(); + + let result = make_tool(&tmp) + .execute(json!({ "name": "calendar" })) + .await + .unwrap(); + + assert!(!result.success); + assert_eq!( + result.error.as_deref(), + Some("Unknown skill 'calendar'. Available skills: weather") + ); + } +}