use std::sync::Arc;
use agent_base::{AgentResult, Content, Tool, ToolContext, ToolMetadata};
use async_trait::async_trait;
use serde_json::{Value, json};
use tokio::task;
use super::resolver::{SkillLookupError, SkillResolver};
use super::telemetry::SkillTelemetry;
const MAX_SKILL_BODY_CHARS: usize = 16_000;
const MAX_ERROR_LISTING: usize = 20;
pub struct SkillTool {
resolver: Arc<SkillResolver>,
telemetry: Arc<SkillTelemetry>,
}
impl SkillTool {
pub fn new(resolver: Arc<SkillResolver>, telemetry: Arc<SkillTelemetry>) -> Self {
Self {
resolver,
telemetry,
}
}
}
#[async_trait]
impl Tool for SkillTool {
fn name(&self) -> &'static str {
"skill"
}
fn description(&self) -> &'static str {
"Load a skill's full instruction body by name. Use this when the user's request matches an available skill (e.g., 'review code' → 'review', 'deploy' → 'ship', 'investigate bug' → 'investigate'). The skill body contains detailed instructions for the task. Returns the skill body, or an error with available skill names if not found."
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The skill's exact name (as listed in the system prompt catalog)."
},
"args": {
"type": "string",
"description": "Optional arguments passed to the skill ($ARGUMENTS placeholder in the body)."
}
},
"required": ["name"]
})
}
fn metadata(&self) -> ToolMetadata {
ToolMetadata {
name: self.name().to_string(),
description: "Load a skill body by exact name.".to_string(),
origin: "agent-works".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
requirements: vec![],
}
}
async fn call(&self, args: &Value, ctx: &ToolContext) -> AgentResult<Vec<Content>> {
let name = match args.get("name").and_then(Value::as_str) {
Some(n) => n.to_string(),
None => {
return Ok(vec![Content::text(
"Error: missing required field `name` (string).".to_string(),
)]);
}
};
let raw_args = args
.get("args")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let resolver = Arc::clone(&self.resolver);
let telemetry = Arc::clone(&self.telemetry);
let max_chars = ctx.max_output_chars.unwrap_or(MAX_SKILL_BODY_CHARS);
let result = task::spawn_blocking(move || {
match resolver.resolve_by_name(&name, &raw_args) {
Ok((body, matched_name)) => {
telemetry.record_model(matched_name);
tracing::info!(
name = %name,
matched = %matched_name,
body_len = body.len(),
"resolved skill via tool"
);
if body.chars().count() <= max_chars {
Ok(body)
} else {
let path_hint = resolver
.source_path_for(matched_name)
.map(|p| format!("\n\n[truncated - full body at: {}]", p.display()))
.unwrap_or_default();
let budget = max_chars.saturating_sub(path_hint.chars().count());
let truncated: String = body.chars().take(budget).collect();
Ok(format!("{truncated}{path_hint}"))
}
}
Err(SkillLookupError::ModelInvocationDenied) => {
tracing::info!(name = %name, "skill tool: model invocation denied");
Err(format!(
"Error: skill \"{name}\" has disable-model-invocation set and cannot be loaded by the model. It can only be run explicitly by the user."
))
}
Err(SkillLookupError::NotFound) => {
let available = resolver.skill_names();
let listing: Vec<&str> =
available.iter().take(MAX_ERROR_LISTING).copied().collect();
let omitted = available.len().saturating_sub(MAX_ERROR_LISTING);
let mut msg = format!(
"Error: no skill named \"{name}\". Available skills: {listing:?}"
);
if omitted > 0 {
msg.push_str(&format!(" (+{omitted} more)"));
}
tracing::info!(name = %name, "skill tool: no match");
Err(msg)
}
}
})
.await;
match result {
Ok(Ok(body)) => Ok(vec![Content::text(body)]),
Ok(Err(msg)) => Ok(vec![Content::text(msg)]),
Err(e) => Ok(vec![Content::text(format!(
"[Error]: skill tool task failed: {e}"
))]),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn make_resolver(skills: &[(&str, &str, bool)]) -> (Arc<SkillResolver>, tempfile::TempDir) {
let tmp = tempfile::tempdir().unwrap();
for (name, body, invocable) in skills {
let dir = tmp.path().join("skills").join(name);
fs::create_dir_all(&dir).unwrap();
let inv = if *invocable { "true" } else { "false" };
fs::write(
dir.join("SKILL.md"),
format!("---\nname: {name}\ndescription: test skill\nuser-invocable: {inv}\n---\n\n{body}"),
)
.unwrap();
}
(
Arc::new(SkillResolver::from_dirs(&[tmp.path().join("skills")])),
tmp,
)
}
fn ctx() -> ToolContext {
ToolContext::for_test()
}
fn text(out: Vec<Content>) -> String {
out.into_iter()
.filter_map(|c| match c {
Content::Text { text } => Some(text),
_ => None,
})
.collect::<Vec<_>>()
.join("\n")
}
#[tokio::test]
async fn happy_path_returns_body() {
let (resolver, _guard) =
make_resolver(&[("code-review", "Review this PR carefully.", true)]);
let tool = SkillTool::new(resolver, Arc::new(SkillTelemetry::new()));
let out = tool
.call(&json!({"name": "code-review"}), &ctx())
.await
.unwrap();
assert_eq!(text(out), "Review this PR carefully.");
}
#[tokio::test]
async fn happy_path_with_args_substitution() {
let (resolver, _guard) = make_resolver(&[("greet", "Hello $ARGUMENTS!", true)]);
let tool = SkillTool::new(resolver, Arc::new(SkillTelemetry::new()));
let out = tool
.call(&json!({"name": "greet", "args": "world"}), &ctx())
.await
.unwrap();
assert_eq!(text(out), "Hello world!");
}
#[tokio::test]
async fn not_found_returns_available_names() {
let (resolver, _guard) = make_resolver(&[("alpha", "a", true), ("beta", "b", true)]);
let tool = SkillTool::new(resolver, Arc::new(SkillTelemetry::new()));
let out = text(tool.call(&json!({"name": "gamma"}), &ctx()).await.unwrap());
assert!(out.contains("no skill named"), "{out}");
assert!(out.contains("alpha"), "{out}");
assert!(out.contains("beta"), "{out}");
}
#[tokio::test]
async fn non_user_invocable_is_accessible_by_tool() {
let (resolver, _guard) = make_resolver(&[("internal", "internal body", false)]);
let tool = SkillTool::new(resolver, Arc::new(SkillTelemetry::new()));
let out = text(
tool.call(&json!({"name": "internal"}), &ctx())
.await
.unwrap(),
);
assert_eq!(out, "internal body");
}
#[tokio::test]
async fn long_body_truncates_with_path_hint() {
let long_body = "x".repeat(17_000);
let (resolver, _guard) = make_resolver(&[("long", &long_body, true)]);
let tool = SkillTool::new(resolver, Arc::new(SkillTelemetry::new()));
let out = text(tool.call(&json!({"name": "long"}), &ctx()).await.unwrap());
assert!(out.contains("[truncated"), "{out}");
assert!(
out.contains("SKILL.md"),
"must include path for read_file continuation: {out}"
);
assert!(
out.chars().count() <= MAX_SKILL_BODY_CHARS,
"overflow output must stay within the budget: {}",
out.chars().count()
);
}
#[tokio::test]
async fn body_exactly_at_char_limit_not_truncated() {
let exact_body = "y".repeat(MAX_SKILL_BODY_CHARS);
let (resolver, _guard) = make_resolver(&[("exact", &exact_body, true)]);
let tool = SkillTool::new(resolver, Arc::new(SkillTelemetry::new()));
let out = text(tool.call(&json!({"name": "exact"}), &ctx()).await.unwrap());
assert_eq!(out.chars().count(), MAX_SKILL_BODY_CHARS);
assert!(
!out.contains("[truncated"),
"at-limit body must not gain a path hint: {out}"
);
}
#[tokio::test]
async fn missing_or_non_string_name_returns_error() {
let (resolver, _guard) = make_resolver(&[("alpha", "a", true)]);
let tool = SkillTool::new(resolver, Arc::new(SkillTelemetry::new()));
for bad in [json!({}), json!({"name": 42})] {
let out = text(tool.call(&bad, &ctx()).await.unwrap());
assert!(
out.contains("missing required field `name`"),
"input {bad} should yield a field error: {out}"
);
}
}
#[tokio::test]
async fn not_found_listing_caps_at_20_with_omitted_count() {
let tmp = tempfile::tempdir().unwrap();
let names: Vec<String> = (0..22).map(|i| format!("skill-{i:02}")).collect();
for name in &names {
let dir = tmp.path().join("skills").join(name);
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("SKILL.md"),
format!("---\nname: {name}\ndescription: d\nuser-invocable: true\n---\n\nbody"),
)
.unwrap();
}
let tool = SkillTool::new(
Arc::new(SkillResolver::from_dirs(&[tmp.path().join("skills")])),
Arc::new(SkillTelemetry::new()),
);
let out = text(tool.call(&json!({"name": "absent"}), &ctx()).await.unwrap());
assert!(out.contains("no skill named \"absent\""), "{out}");
assert!(out.contains("skill-00"), "{out}");
assert!(out.contains("skill-19"), "{out}");
assert!(
!out.contains("skill-20"),
"listing must cap at {MAX_ERROR_LISTING}: {out}"
);
assert!(out.contains("(+2 more)"), "{out}");
}
#[tokio::test]
async fn model_denied_skill_returns_error_not_body() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("skills").join("deploy");
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("SKILL.md"),
"---\nname: deploy\ndescription: d\nuser-invocable: true\ndisable-model-invocation: true\n---\n\nsecret ship steps",
)
.unwrap();
let tool = SkillTool::new(
Arc::new(SkillResolver::from_dirs(&[tmp.path().join("skills")])),
Arc::new(SkillTelemetry::new()),
);
let out = text(tool.call(&json!({"name": "deploy"}), &ctx()).await.unwrap());
assert!(out.contains("Error:"), "{out}");
assert!(out.contains("disable-model-invocation"), "{out}");
assert!(
!out.contains("secret ship steps"),
"denied body must not leak: {out}"
);
}
}