diff --git a/src/agent/context_compressor.rs b/src/agent/context_compressor.rs index 784d858..3904469 100644 --- a/src/agent/context_compressor.rs +++ b/src/agent/context_compressor.rs @@ -605,7 +605,7 @@ mod tests { #[test] fn test_threshold() { let compressor = ContextCompressor::new(mock_provider(), 128_000, test_memory_manager()); - assert_eq!(compressor.threshold(), 64_000); + assert_eq!(compressor.threshold(), 89_600); } #[tokio::test] diff --git a/src/session/session.rs b/src/session/session.rs index f05a1e4..af9851f 100644 --- a/src/session/session.rs +++ b/src/session/session.rs @@ -487,7 +487,7 @@ impl Session { if skills_prompt.trim().is_empty() { base_prompt } else { - format!("{}\n\n## Skills\n\n{}\n\nUse the `get_skill` tool to load a skill's full content when needed.", base_prompt, skills_prompt) + format!("{}\n\n{}", base_prompt, skills_prompt) } } @@ -810,8 +810,9 @@ impl SessionManager { bus: Arc, memory_manager: Arc, ) -> Result { - let skills_loader = SkillsLoader::new(); + let mut skills_loader = SkillsLoader::new(); skills_loader.load_skills(); + skills_loader.set_workspace_skills_dir(provider_config.workspace_dir.clone()); let skills_loader = Arc::new(skills_loader); let tools = Arc::new(create_default_tools(skills_loader.clone(), memory_manager.clone())); diff --git a/src/skills/mod.rs b/src/skills/mod.rs index 777b692..f8ebaa8 100644 --- a/src/skills/mod.rs +++ b/src/skills/mod.rs @@ -24,6 +24,7 @@ struct SkillsState { loaded_skills: Vec, last_picobot_mtime: Option, last_agent_mtime: Option, + last_workspace_mtime: Option, last_load_time: SystemTime, } @@ -33,6 +34,7 @@ impl Default for SkillsState { loaded_skills: Vec::new(), last_picobot_mtime: None, last_agent_mtime: None, + last_workspace_mtime: None, last_load_time: SystemTime::now(), } } @@ -43,6 +45,7 @@ impl Default for SkillsState { pub struct SkillsLoader { picobot_skills_dir: PathBuf, agent_skills_dir: PathBuf, + workspace_skills_dir: Option, state: Arc>, } @@ -53,6 +56,7 @@ impl SkillsLoader { Self { picobot_skills_dir: home.join(".picobot/skills"), agent_skills_dir: home.join(".agent/skills"), + workspace_skills_dir: None, state: Arc::new(Mutex::new(SkillsState::default())), } } @@ -62,10 +66,16 @@ impl SkillsLoader { Self { picobot_skills_dir: picobot_dir, agent_skills_dir: agent_dir, + workspace_skills_dir: None, state: Arc::new(Mutex::new(SkillsState::default())), } } + /// Set the workspace skills directory (./skills under workspace root) + pub fn set_workspace_skills_dir(&mut self, workspace_path: PathBuf) { + self.workspace_skills_dir = Some(workspace_path.join("skills")); + } + /// Load all skills from both directories and record modification times pub fn load_skills(&self) { let mut state = self.state.lock().unwrap(); @@ -104,6 +114,20 @@ impl SkillsLoader { state.last_agent_mtime = Self::get_dir_mtime(&self.agent_skills_dir); } + // Load from workspace ./skills (if set) + if let Some(ref ws_dir) = self.workspace_skills_dir { + if ws_dir.exists() { + let loaded = self.load_skills_from_dir(ws_dir); + tracing::debug!( + dir = %ws_dir.display(), + count = loaded.len(), + "Loaded skills from workspace directory" + ); + state.loaded_skills.extend(loaded); + state.last_workspace_mtime = Self::get_dir_mtime(ws_dir); + } + } + state.last_load_time = SystemTime::now(); if state.loaded_skills.is_empty() { @@ -130,7 +154,18 @@ impl SkillsLoader { false }; - picobot_changed || agent_changed + let workspace_changed = if let Some(ref ws_dir) = self.workspace_skills_dir { + if ws_dir.exists() { + let current_mtime = Self::get_dir_mtime(ws_dir); + current_mtime != state.last_workspace_mtime + } else { + false + } + } else { + false + }; + + picobot_changed || agent_changed || workspace_changed } /// Reload skills if changes are detected @@ -247,46 +282,53 @@ impl SkillsLoader { parts.join("\n\n---\n\n") } - /// Build full skills prompt combining always skills and summary (checks for changes first) + /// Build full skills prompt: directory conventions, always-skill summary, always-skill content pub fn build_skills_prompt(&self) -> String { self.reload_if_changed(); let state = self.state.lock().unwrap(); - - let mut prompt = String::new(); + if state.loaded_skills.is_empty() { + return String::new(); + } + + let mut prompt = String::from("## Skills\n\n"); + + // Directory conventions + prompt.push_str("### 目录说明\n\n"); + prompt.push_str("- `~/.agent/skills/` — 外部共享 skill 目录(第三方、系统级 skill)\n"); + prompt.push_str("- `~/.picobot/skills/` — 安装 skill 的默认目录\n"); + prompt.push_str("- `./skills/` — 工作目录下的 skill,picobot 自行创建的 skill 存放于此\n\n"); + prompt.push_str("安装或创建 skill 时请按上述目录规范存放。\n\n"); + + // Always skills summary let always_skills: Vec<_> = state.loaded_skills.iter().filter(|s| s.always).collect(); if !always_skills.is_empty() { + prompt.push_str("### 常用技能\n\n"); + for skill in &always_skills { + let path_str = skill.path.as_ref() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| "—".to_string()); + prompt.push_str(&format!( + "- **{}**: {} [路径: `{}`]\n", + skill.name, skill.description, path_str + )); + } + prompt.push('\n'); + } + + // Usage instructions + prompt.push_str("### 使用方法\n\n"); + prompt.push_str("- 使用 `get_skill` 工具 action=\"list\" 列出所有可用 skill 及其名称、简介、路径\n"); + prompt.push_str("- 使用 `get_skill` 工具 action=\"get\" 并提供 `skill_name` 获取指定 skill 完整内容\n"); + + // Always skills full content + if !always_skills.is_empty() { + prompt.push_str("\n---\n\n"); let mut parts = Vec::new(); - for skill in always_skills { + for skill in &always_skills { parts.push(format!("## Skill: {}\n\n{}", skill.name, skill.content)); } prompt.push_str(&parts.join("\n\n---\n\n")); - prompt.push_str("\n\n"); - } - - let has_other_skills = state.loaded_skills.iter().any(|s| !s.always); - if has_other_skills { - prompt.push_str("## Available Skills\n\n"); - prompt.push_str("Skills teach the agent how to use specific capabilities. Use the `get_skill` tool to load a skill's full content when needed.\n\n"); - - let mut lines = vec!["".to_string()]; - for skill in &state.loaded_skills { - if skill.always { - continue; - } - lines.push(" ".to_string()); - lines.push(format!(" {}", escape_xml(&skill.name))); - lines.push(format!( - " {}", - escape_xml(&skill.description) - )); - if let Some(path) = &skill.path { - lines.push(format!(" {}", escape_xml(&path.to_string_lossy()))); - } - lines.push(" ".to_string()); - } - lines.push("".to_string()); - prompt.push_str(&lines.join("\n")); } prompt diff --git a/src/tools/get_skill.rs b/src/tools/get_skill.rs index 8f071d3..22e4588 100644 --- a/src/tools/get_skill.rs +++ b/src/tools/get_skill.rs @@ -44,12 +44,17 @@ impl Tool for GetSkillTool { json!({ "type": "object", "properties": { + "action": { + "type": "string", + "enum": ["get", "list"], + "description": "操作类型: get 获取指定 skill 完整内容, list 列出所有可用 skill" + }, "skill_name": { "type": "string", - "description": "Name of the skill to retrieve" + "description": "Name of the skill to retrieve,仅在 action 为 get 时必填" } }, - "required": ["skill_name"] + "required": [] }) } @@ -58,6 +63,17 @@ impl Tool for GetSkillTool { } async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let action = args.get("action").and_then(|v| v.as_str()).unwrap_or("get"); + + match action { + "list" => self.list_skills_full(), + _ => self.get_skill_by_name(&args), + } + } +} + +impl GetSkillTool { + fn get_skill_by_name(&self, args: &serde_json::Value) -> anyhow::Result { let skill_name = match args.get("skill_name").and_then(|v| v.as_str()) { Some(name) => name, None => { @@ -100,6 +116,33 @@ impl Tool for GetSkillTool { } } } + + fn list_skills_full(&self) -> anyhow::Result { + let skills = self.skills_loader.get_loaded_skills(); + if skills.is_empty() { + return Ok(ToolResult { + success: true, + output: "当前没有安装任何 skill".to_string(), + error: None, + }); + } + let mut output = format!("可用 skill (共 {} 个):\n", skills.len()); + for s in &skills { + let always_mark = if s.always { " [常驻]" } else { "" }; + let path_str = s.path.as_ref() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| "—".to_string()); + output.push_str(&format!( + "- {}{}\n 简介: {}\n 路径: {}\n", + s.name, always_mark, s.description, path_str + )); + } + Ok(ToolResult { + success: true, + output, + error: None, + }) + } } #[cfg(test)]