diff --git a/crates/agent/src/session.rs b/crates/agent/src/session.rs index d7c32f77a..0f267324d 100644 --- a/crates/agent/src/session.rs +++ b/crates/agent/src/session.rs @@ -7,7 +7,7 @@ use crate::loop_detection::detect_loop; use crate::profiles::EnvContext; use crate::project_docs::discover_project_docs; use crate::provider_profile::ProviderProfile; -use crate::skills::{default_skill_dirs, discover_skills, expand_skill, Skill}; +use crate::skills::{default_skill_dirs, discover_skills, expand_skill, make_use_skill_tool, Skill}; use crate::tool_registry::ToolRegistry; use crate::truncation::truncate_tool_output; use crate::types::{AgentEvent, SessionState, Turn}; @@ -95,6 +95,14 @@ impl Session { }; self.skills = discover_skills(self.execution_env.as_ref(), &skill_dirs).await; + // Register use_skill tool when skills are available + if !self.skills.is_empty() { + let skills_arc = Arc::new(self.skills.clone()); + if let Some(profile) = Arc::get_mut(&mut self.provider_profile) { + profile.tool_registry_mut().register(make_use_skill_tool(skills_arc)); + } + } + // Populate environment context self.env_context = self.build_env_context().await; diff --git a/crates/agent/src/skills.rs b/crates/agent/src/skills.rs index 8c1525c53..e5c87cd5a 100644 --- a/crates/agent/src/skills.rs +++ b/crates/agent/src/skills.rs @@ -1,4 +1,8 @@ use crate::execution_env::ExecutionEnvironment; +use crate::tool_registry::RegisteredTool; +use crate::tools::required_str; +use llm::types::ToolDefinition; +use std::sync::Arc; #[derive(Debug, Clone)] pub struct Skill { @@ -148,17 +152,54 @@ pub fn expand_skill(skills: &[Skill], input: &str) -> Result>) -> RegisteredTool { + RegisteredTool { + definition: ToolDefinition { + name: "use_skill".into(), + description: "Load a skill's instructions by name. Call this when the user's \ + request matches an available skill." + .into(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "skill_name": { + "type": "string", + "description": "Name of the skill to load (without the / prefix)" + } + }, + "required": ["skill_name"] + }), + }, + executor: Arc::new(move |args, _env, _cancel| { + let skills = skills.clone(); + Box::pin(async move { + let name = required_str(&args, "skill_name")?; + let skill = skills + .iter() + .find(|s| s.name == name) + .ok_or_else(|| format!("Unknown skill: {name}"))?; + Ok(skill.template.clone()) + }) + }), + } +} + pub fn format_skills_prompt_section(skills: &[Skill]) -> String { if skills.is_empty() { return String::new(); } - let mut lines = vec!["# Available Skills".to_string()]; + let mut lines = vec![ + "# Available Skills".to_string(), + "When the user's request matches a skill below, call the `use_skill` tool \ + to load its instructions, then follow them." + .to_string(), + ]; for skill in skills { if skill.description.is_empty() { - lines.push(format!("- /{}", skill.name)); + lines.push(format!("- `{}`", skill.name)); } else { - lines.push(format!("- /{}: {}", skill.name, skill.description)); + lines.push(format!("- `{}`: {}", skill.name, skill.description)); } } lines.join("\n") @@ -392,8 +433,9 @@ name: trimmed let skills = test_skills(); let section = format_skills_prompt_section(&skills); assert!(section.contains("# Available Skills")); - assert!(section.contains("- /commit: Create a commit")); - assert!(section.contains("- /test: Run tests")); + assert!(section.contains("call the `use_skill` tool")); + assert!(section.contains("- `commit`: Create a commit")); + assert!(section.contains("- `test`: Run tests")); } // --- discover_skills tests --- @@ -491,4 +533,47 @@ name: trimmed let dirs = default_skill_dirs(Some("/home/user"), None); assert_eq!(dirs, vec!["/home/user/.attractor/skills"]); } + + // --- make_use_skill_tool tests --- + + #[tokio::test] + async fn use_skill_tool_returns_template() { + let skills = Arc::new(test_skills()); + let tool = make_use_skill_tool(skills); + + let env: Arc = + Arc::new(MockExecutionEnvironment::default()); + let args = serde_json::json!({"skill_name": "commit"}); + let result = (tool.executor)(args, env, tokio_util::sync::CancellationToken::new()).await; + assert_eq!( + result.unwrap(), + "Review changes and commit.\n\n{{user_input}}" + ); + } + + #[tokio::test] + async fn use_skill_tool_unknown_skill_errors() { + let skills = Arc::new(test_skills()); + let tool = make_use_skill_tool(skills); + + let env: Arc = + Arc::new(MockExecutionEnvironment::default()); + let args = serde_json::json!({"skill_name": "nonexistent"}); + let result = (tool.executor)(args, env, tokio_util::sync::CancellationToken::new()).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Unknown skill")); + } + + #[tokio::test] + async fn use_skill_tool_missing_param_errors() { + let skills = Arc::new(test_skills()); + let tool = make_use_skill_tool(skills); + + let env: Arc = + Arc::new(MockExecutionEnvironment::default()); + let args = serde_json::json!({}); + let result = (tool.executor)(args, env, tokio_util::sync::CancellationToken::new()).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Missing required parameter")); + } }