use std::collections::BTreeMap;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use agent_client_protocol_schema::{
Content, ContentBlock, TextContent, ToolCallContent, ToolCallUpdateFields, ToolKind,
};
use futures::future::BoxFuture;
use serde::Deserialize;
use serde_json::json;
use crate::error::BoxError;
use crate::tool::{
SafetyClass, Tool, ToolCallDescription, ToolContext, ToolError, ToolEvent, ToolSchema,
ToolStream,
};
pub(crate) const SKILL_TOOL_NAME: &str = "skill";
#[derive(Debug, Clone, Default)]
pub struct SkillTriggers {
pub globs: Option<globset::GlobSet>,
pub keywords: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct SkillEntry {
pub description: String,
pub body: String,
pub dir: PathBuf,
pub always: bool,
pub triggers: SkillTriggers,
}
pub struct SkillTool {
schema: ToolSchema,
skills: Arc<BTreeMap<String, SkillEntry>>,
}
impl SkillTool {
pub fn new(skills: Arc<BTreeMap<String, SkillEntry>>) -> Self {
let schema = build_schema(&skills);
Self { schema, skills }
}
pub fn has_skills(skills: &BTreeMap<String, SkillEntry>) -> bool {
!skills.is_empty()
}
}
fn build_schema(skills: &BTreeMap<String, SkillEntry>) -> ToolSchema {
let names: Vec<&str> = skills.keys().map(String::as_str).collect();
let catalog = skills
.iter()
.map(|(name, s)| format!("- {name}: {}", s.description))
.collect::<Vec<_>>()
.join("\n");
let description = format!(
"Load the full instructions for a specialized skill into the current conversation. \
Use this when the task at hand matches one of the skills below; the loaded content may \
contain detailed workflow guidance plus references to scripts / files in the skill's \
directory that you can then read with `bash` / `read_file`. After loading, carry out the \
task in this same conversation.\n\n\
Available skills:\n{catalog}"
);
ToolSchema {
name: SKILL_TOOL_NAME.to_string(),
description,
input_schema: json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"enum": names,
"description": "Which skill to load. See the tool description for what each skill does."
}
},
"required": ["name"]
}),
}
}
#[derive(Debug, Deserialize)]
struct SkillArgs {
name: String,
}
impl Tool for SkillTool {
fn schema(&self) -> &ToolSchema {
&self.schema
}
fn safety_hint(&self, _args: &serde_json::Value) -> SafetyClass {
SafetyClass::ReadOnly
}
fn describe<'a>(
&'a self,
args: &'a serde_json::Value,
_ctx: ToolContext<'a>,
) -> BoxFuture<'a, ToolCallDescription> {
Box::pin(async move {
let name = args.get("name").and_then(|v| v.as_str()).unwrap_or("?");
let mut fields = ToolCallUpdateFields::default();
fields.title = Some(format!("Load skill `{name}`"));
fields.kind = Some(ToolKind::Think);
ToolCallDescription { fields }
})
}
fn execute(&self, args: serde_json::Value, _ctx: ToolContext<'_>) -> ToolStream {
let skills = self.skills.clone();
let fut = async move {
let parsed: SkillArgs = match serde_json::from_value(args) {
Ok(v) => v,
Err(err) => return ToolEvent::Failed(ToolError::InvalidArgs(BoxError::new(err))),
};
let Some(skill) = skills.get(&parsed.name) else {
return ToolEvent::Failed(ToolError::InvalidArgs(BoxError::new(io_err(format!(
"unknown skill `{}`; available: {}",
parsed.name,
skills.keys().cloned().collect::<Vec<_>>().join(", ")
)))));
};
let output = render_skill(&parsed.name, skill);
let mut fields = ToolCallUpdateFields::default();
fields.content = Some(vec![ToolCallContent::Content(Content::new(
ContentBlock::Text(TextContent::new(output.clone())),
))]);
fields.raw_output = Some(serde_json::Value::String(output));
ToolEvent::Completed(fields)
};
let s: Pin<Box<dyn futures::Stream<Item = ToolEvent> + Send>> =
Box::pin(futures::stream::once(fut));
s
}
}
fn render_skill(name: &str, skill: &SkillEntry) -> String {
format!(
"# Skill: {name}\n\n{body}\n\n\
Skill directory: {dir}\n\
Relative paths in this skill (e.g. scripts/, refs/) are relative to that directory; \
read them with `read_file` / `bash` as needed.",
body = skill.body,
dir = skill.dir.display(),
)
}
fn io_err(msg: String) -> std::io::Error {
std::io::Error::other(msg)
}
#[cfg(test)]
mod tests;