use std::sync::Arc;
use async_trait::async_trait;
use once_cell::sync::Lazy;
use once_cell::sync::OnceCell;
use serde_json::{Value, json};
use theway_core::{
AgentHarness, AgentTool, AgentToolError, AgentToolResult, AgentToolUpdate, ToolExecutionMode,
format_skill_invocation,
};
use theway_llm_provider::{Tool, UserContentBlock};
use tokio_util::sync::CancellationToken;
pub type SkillHarnessCell = Arc<OnceCell<Arc<AgentHarness>>>;
pub struct SkillTool {
harness: SkillHarnessCell,
}
impl SkillTool {
pub fn new(harness: SkillHarnessCell) -> Self {
Self { harness }
}
}
#[async_trait]
impl AgentTool for SkillTool {
fn definition(&self) -> &Tool {
&DEFINITION
}
fn label(&self) -> &str {
"skill"
}
fn execution_mode(&self) -> Option<ToolExecutionMode> {
Some(ToolExecutionMode::Parallel)
}
async fn execute(
&self,
_id: &str,
params: Value,
_cancel: CancellationToken,
_on_update: Option<AgentToolUpdate>,
) -> Result<AgentToolResult, AgentToolError> {
let name = params
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| AgentToolError::from("missing required arg: name"))?
.to_string();
let harness = match self.harness.get() {
Some(h) => h,
None => {
return Err(AgentToolError::from("skill tool not yet initialized"));
}
};
let skills = harness.skills();
let Some(skill) = skills.iter().find(|s| s.name == name) else {
return Err(AgentToolError::Message(format!(
"no skill named '{name}'. Use /skills to list available skills."
)));
};
if skill.disable_model_invocation {
return Err(AgentToolError::Message(format!(
"skill '{name}' is disabled (disable_model_invocation=true); update the frontmatter to enable"
)));
}
let body = format_skill_invocation(skill, None);
Ok(AgentToolResult {
content: vec![UserContentBlock::text(body)],
details: json!({ "name": skill.name, "path": skill.file_path }),
terminate: None,
})
}
}
static DEFINITION: Lazy<Tool> = Lazy::new(|| Tool {
name: "skill".into(),
description:
"Invoke a skill by name. Returns the skill body wrapped in a `<skill>` block for the \
model to follow. Use this when the skill registry in the system prompt indicates the \
skill is relevant to the current task. The skill name must match exactly an entry in \
the registry."
.into(),
parameters: json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Exact skill name as listed in the system-prompt registry.",
},
},
"required": ["name"],
"additionalProperties": false,
}),
});
#[cfg(test)]
mod tests {
use super::*;
use once_cell::sync::OnceCell as SyncOnceCell;
use std::sync::Arc;
use theway_core::{
AgentHarnessOptions, MemorySessionStorage, Session, SessionStorage, Skill, SkillSource,
};
use theway_llm_provider::{Api, Model, ModelCost, Provider};
fn fake_model() -> Model {
Model {
id: "faux".into(),
name: "Faux".into(),
api: Api::from("faux"),
provider: Provider::from("faux"),
base_url: String::new(),
reasoning: false,
thinking_level_map: None,
input: vec![],
cost: ModelCost::default(),
context_window: 0,
max_tokens: 0,
headers: None,
compat: None,
}
}
fn build_harness_with_skills(skills: Vec<Skill>) -> Arc<AgentHarness> {
let storage = Arc::new(MemorySessionStorage::new()) as Arc<dyn SessionStorage>;
let session = Session::new(storage);
let mut opts = AgentHarnessOptions::new(Some(fake_model()), session);
opts.skills = skills;
Arc::new(AgentHarness::new(opts))
}
fn make_skill(name: &str, disabled: bool) -> Skill {
Skill {
name: name.into(),
description: format!("description of {name}"),
file_path: format!("/tmp/skills/{name}/SKILL.md"),
content: format!("# {name}\n\nBody of the {name} skill."),
disable_model_invocation: disabled,
source: SkillSource::User,
}
}
async fn execute(tool: &SkillTool, name: &str) -> Result<AgentToolResult, AgentToolError> {
tool.execute(
"call-1",
json!({ "name": name }),
CancellationToken::new(),
None,
)
.await
}
#[tokio::test]
async fn hit_returns_wrapped_body() {
let cell: SkillHarnessCell = Arc::new(SyncOnceCell::new());
let harness = build_harness_with_skills(vec![make_skill("alpha", false)]);
assert!(cell.set(harness).is_ok(), "set once");
let tool = SkillTool::new(cell);
let result = execute(&tool, "alpha").await.expect("hit should succeed");
let UserContentBlock::Text(text) = &result.content[0] else {
panic!("expected text content");
};
assert!(text.text.contains("<skill name=\"alpha\""));
assert!(text.text.contains("Body of the alpha skill."));
}
#[tokio::test]
async fn miss_returns_typed_error() {
let cell: SkillHarnessCell = Arc::new(SyncOnceCell::new());
let harness = build_harness_with_skills(vec![make_skill("alpha", false)]);
assert!(cell.set(harness).is_ok(), "set once");
let tool = SkillTool::new(cell);
let err = execute(&tool, "nonexistent")
.await
.expect_err("miss should fail");
let AgentToolError::Message(msg) = err else {
panic!("expected Message error");
};
assert!(msg.contains("no skill named 'nonexistent'"));
assert!(msg.contains("/skills"));
}
#[tokio::test]
async fn disabled_skill_refuses_body_via_model_path() {
let cell: SkillHarnessCell = Arc::new(SyncOnceCell::new());
let harness = build_harness_with_skills(vec![make_skill("locked", true)]);
assert!(cell.set(harness).is_ok(), "set once");
let tool = SkillTool::new(cell);
let err = execute(&tool, "locked")
.await
.expect_err("disabled skill should refuse");
let AgentToolError::Message(msg) = err else {
panic!("expected Message error");
};
assert!(msg.contains("disabled"));
assert!(msg.contains("disable_model_invocation"));
assert!(msg.contains("frontmatter"));
}
#[tokio::test]
async fn disabled_skill_refuses_body_via_steering_path() {
let cell: SkillHarnessCell = Arc::new(SyncOnceCell::new());
let harness = build_harness_with_skills(vec![make_skill("locked", true)]);
assert!(cell.set(harness).is_ok(), "set once");
let tool = SkillTool::new(cell);
let err = execute(&tool, "locked")
.await
.expect_err("disabled via steering path should still refuse");
let AgentToolError::Message(msg) = err else {
panic!("expected Message error");
};
assert!(msg.contains("disabled"));
assert!(msg.contains("disable_model_invocation"));
}
#[tokio::test]
async fn unset_harness_cell_returns_recoverable_error_not_panic() {
let cell: SkillHarnessCell = Arc::new(SyncOnceCell::new());
let tool = SkillTool::new(cell);
let err = execute(&tool, "anything")
.await
.expect_err("unset cell should fail with a typed error");
let AgentToolError::Message(msg) = err else {
panic!("expected Message error, not a panic");
};
assert!(msg.contains("skill tool not yet initialized"));
}
#[tokio::test]
async fn missing_name_arg_is_a_typed_error() {
let cell: SkillHarnessCell = Arc::new(SyncOnceCell::new());
let harness = build_harness_with_skills(Vec::new());
assert!(cell.set(harness).is_ok(), "set once");
let tool = SkillTool::new(cell);
let err = tool
.execute("call", json!({}), CancellationToken::new(), None)
.await
.expect_err("missing arg should fail");
let AgentToolError::Message(msg) = err else {
panic!("expected Message error");
};
assert!(msg.contains("missing required arg: name"));
}
}
#[cfg(test)]
mod coverage_gap {
use super::*;
use once_cell::sync::OnceCell as SyncOnceCell;
use std::sync::Arc;
use theway_core::{AgentHarnessOptions, MemorySessionStorage, Session, SessionStorage};
#[test]
fn definition_label_and_execution_mode_are_registered() {
let cell: SkillHarnessCell = Arc::new(SyncOnceCell::new());
let tool = SkillTool::new(cell);
assert_eq!(tool.definition().name, "skill");
assert_eq!(tool.label(), "skill");
assert_eq!(tool.execution_mode(), Some(ToolExecutionMode::Parallel));
assert_eq!(tool.definition().parameters["required"][0], "name");
}
#[tokio::test]
async fn hit_with_empty_skills_catalog_misses() {
let storage = Arc::new(MemorySessionStorage::new()) as Arc<dyn SessionStorage>;
let session = Session::new(storage);
let mut opts = AgentHarnessOptions::new(None, session);
opts.skills = Vec::new();
let harness = Arc::new(AgentHarness::new(opts));
let cell: SkillHarnessCell = Arc::new(SyncOnceCell::new());
assert!(cell.set(harness).is_ok(), "set once");
let tool = SkillTool::new(cell);
let err = tool
.execute(
"call-1",
json!({ "name": "ghost" }),
CancellationToken::new(),
None,
)
.await
.expect_err("empty catalog must miss");
let msg = err.to_string();
assert!(msg.contains("no skill named 'ghost'"), "got: {msg}");
}
#[tokio::test]
async fn non_string_name_is_rejected() {
let cell: SkillHarnessCell = Arc::new(SyncOnceCell::new());
let tool = SkillTool::new(cell);
let err = tool
.execute(
"call-1",
json!({ "name": 42 }),
CancellationToken::new(),
None,
)
.await
.expect_err("non-string name should be rejected");
assert!(
err.to_string().contains("missing required arg: name"),
"got: {err}"
);
}
}