Add use_skill tool for agent-initiated skill loading

Let the agent autonomously load skill templates when it recognizes a
matching task, instead of requiring users to type /skill-name. The
system prompt now instructs the agent to call `use_skill` and skill
names use backtick formatting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-02-27 22:03:16 -05:00
parent f6c711fdd9
commit 35005ca0fe
2 changed files with 99 additions and 6 deletions

View file

@ -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;

View file

@ -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<ExpandedInput, Stri
})
}
pub fn make_use_skill_tool(skills: Arc<Vec<Skill>>) -> 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<dyn crate::execution_env::ExecutionEnvironment> =
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<dyn crate::execution_env::ExecutionEnvironment> =
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<dyn crate::execution_env::ExecutionEnvironment> =
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"));
}
}