use super::{Capabilities, CarriedState, Tool, ToolCtx, ToolOutput};
use crate::skill::Skill;
use anyhow::Result;
use async_trait::async_trait;
use serde_json::{json, Value};
use std::path::PathBuf;
use std::sync::Mutex;
pub struct SkillTool {
available: Vec<Skill>,
loaded: Mutex<Vec<String>>,
}
impl SkillTool {
pub fn new(available: Vec<Skill>) -> Self {
SkillTool {
available,
loaded: Mutex::new(Vec::new()),
}
}
pub fn loaded(&self) -> Vec<String> {
self.loaded.lock().unwrap().clone()
}
pub fn available(&self) -> &[Skill] {
&self.available
}
pub fn clear(&self) {
self.loaded.lock().unwrap().clear();
}
fn skill(&self, name: &str) -> Option<&Skill> {
self.available.iter().find(|s| s.name == name)
}
fn render(skill: &Skill) -> String {
format!(
"# Skill: {}\n\
If this procedure points at a file bundled with it, call `skill` \
again with `file` set to that name — the ordinary file tools \
cannot reach it, since a skill lives outside the workspace.\n\n{}",
skill.name, skill.body
)
}
fn resolve_bundled(skill: &Skill, file: &str) -> Result<PathBuf, String> {
let root = skill
.dir
.canonicalize()
.map_err(|e| format!("cannot read the skill's directory: {e}"))?;
let candidate = root.join(file);
let resolved = candidate
.canonicalize()
.map_err(|_| format!("no file `{file}` bundled with skill `{}`", skill.name))?;
if !resolved.starts_with(&root) {
return Err(format!(
"`{file}` resolves outside skill `{}` — a bundled file has to be \
inside the skill's own directory",
skill.name
));
}
if !resolved.is_file() {
return Err(format!("`{file}` is not a file"));
}
Ok(resolved)
}
}
const MAX_BUNDLED_BYTES: usize = 60_000;
const CARRIED_BUDGET: usize = 24_000;
fn floor_char_boundary(s: &str, max: usize) -> usize {
if max >= s.len() {
return s.len();
}
let mut end = max;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
end
}
#[async_trait]
impl Tool for SkillTool {
fn name(&self) -> &str {
"skill"
}
fn description(&self) -> &str {
"Load the full instructions for one of the skills listed in your system \
prompt. Call this before starting work the skill covers, then follow what \
it says. The skills are procedures the user wrote for you, so they are more \
specific than your general judgement about how to do the task."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The skill's name, exactly as listed in the system prompt."
},
"file": {
"type": "string",
"description": "Optional: a file bundled with the skill, named by its procedure. Omit to load the procedure itself."
}
},
"required": ["name"]
})
}
fn read_only(&self) -> bool {
true
}
fn capabilities(&self) -> Capabilities {
Capabilities::default()
}
async fn call(&self, input: Value, _ctx: &ToolCtx) -> Result<ToolOutput> {
let Some(name) = input.get("name").and_then(Value::as_str) else {
return Ok(ToolOutput::err("`name` is required, and must be a string"));
};
let name = name.trim();
let Some(skill) = self.skill(name) else {
let known: Vec<&str> = self.available.iter().map(|s| s.name.as_str()).collect();
return Ok(ToolOutput::err(if known.is_empty() {
"no skills are enabled for this run".to_string()
} else {
format!("no skill named `{name}`. Enabled: {}", known.join(", "))
}));
};
if let Some(file) = input.get("file").and_then(Value::as_str) {
return Ok(match Self::resolve_bundled(skill, file.trim()) {
Err(why) => ToolOutput::err(why),
Ok(path) => match std::fs::read_to_string(&path) {
Err(e) => ToolOutput::err(format!("cannot read `{file}`: {e}")),
Ok(text) if text.len() > MAX_BUNDLED_BYTES => {
let end = floor_char_boundary(&text, MAX_BUNDLED_BYTES);
ToolOutput::ok(format!(
"{}\n\n[cut: `{file}` is {} bytes, over the {MAX_BUNDLED_BYTES}-byte \
ceiling for one bundled file]",
&text[..end],
text.len()
))
}
Ok(text) => ToolOutput::ok(text),
},
});
}
let mut loaded = self.loaded.lock().unwrap();
if !loaded.iter().any(|n| n == name) {
loaded.push(name.to_string());
}
drop(loaded);
Ok(ToolOutput::ok(Self::render(skill)))
}
fn carried_state(&self, _ctx: &ToolCtx) -> Option<CarriedState> {
let loaded = self.loaded.lock().unwrap();
if loaded.is_empty() {
return None;
}
let mut kept: Vec<String> = Vec::new();
let mut dropped: Vec<&str> = Vec::new();
let mut budget = CARRIED_BUDGET;
for skill in loaded.iter().rev().filter_map(|n| self.skill(n)) {
let rendered = Self::render(skill);
if rendered.len() <= budget {
budget -= rendered.len();
kept.push(rendered);
} else {
dropped.push(skill.name.as_str());
}
}
if kept.is_empty() && dropped.is_empty() {
return None;
}
kept.reverse();
dropped.reverse();
let mut body = format!(
"Skills loaded in this session, reproduced in full because a \
summary of a procedure is a different procedure:\n\n{}",
kept.join("\n\n---\n\n")
);
if !dropped.is_empty() {
body.push_str(&format!(
"\n\n[also loaded earlier, too long to carry: {}. Call `skill` again if \
you need one of them.]",
dropped.join(", ")
));
}
Some(CarriedState {
label: "skill".to_string(),
body,
})
}
fn forget_conversation_state(&self) {
self.clear();
}
fn narrows_surface_to(&self) -> Option<Vec<String>> {
let loaded = self.loaded.lock().unwrap();
let mut names: Vec<String> = Vec::new();
let mut any = false;
for skill in loaded.iter().filter_map(|n| self.skill(n)) {
if let Some(tools) = &skill.tools {
any = true;
names.extend(tools.iter().cloned());
}
}
any.then_some(names)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn skill(name: &str, tools: Option<Vec<&str>>) -> Skill {
Skill {
name: name.to_string(),
description: "d".into(),
triggers: Vec::new(),
tools: tools.map(|t| t.into_iter().map(String::from).collect()),
body: format!("the {name} procedure"),
dir: PathBuf::from("/tmp/skills").join(name),
}
}
async fn load(tool: &SkillTool, name: &str) -> ToolOutput {
tool.call(json!({ "name": name }), &ToolCtx::default())
.await
.unwrap()
}
#[tokio::test]
async fn loading_returns_the_body_verbatim_and_names_the_directory() {
let tool = SkillTool::new(vec![skill("audit", None)]);
let out = load(&tool, "audit").await;
assert!(!out.is_error);
assert!(
out.content.contains("the audit procedure"),
"{}",
out.content
);
assert!(
out.content.contains("call `skill` again with `file`"),
"level 3 has to be reachable, and only through this tool: {}",
out.content
);
assert_eq!(tool.loaded(), vec!["audit"]);
}
#[tokio::test]
async fn a_loaded_skill_is_never_third_party_content() {
let tool = SkillTool::new(vec![skill("audit", None)]);
let out = load(&tool, "audit").await;
assert!(
!out.external,
"a user-authored procedure is not outside input"
);
assert_eq!(tool.capabilities(), Capabilities::default());
}
#[tokio::test]
async fn an_unknown_name_lists_what_is_enabled_rather_than_failing_blind() {
let tool = SkillTool::new(vec![skill("audit", None), skill("brief", None)]);
let out = load(&tool, "audi").await;
assert!(out.is_error);
assert!(out.content.contains("audit") && out.content.contains("brief"));
assert!(tool.loaded().is_empty(), "a failed load is not a load");
}
#[tokio::test]
async fn re_loading_hands_the_body_back_rather_than_declining() {
let tool = SkillTool::new(vec![skill("audit", None)]);
let first = load(&tool, "audit").await;
let again = load(&tool, "audit").await;
assert_eq!(first.content, again.content);
assert_eq!(tool.loaded(), vec!["audit"], "and it is not counted twice");
}
#[tokio::test]
async fn nothing_is_carried_across_a_compaction_until_something_is_loaded() {
let tool = SkillTool::new(vec![skill("audit", None)]);
assert!(tool.carried_state(&ToolCtx::default()).is_none());
load(&tool, "audit").await;
let carried = tool.carried_state(&ToolCtx::default()).unwrap();
assert!(
carried.body.contains("the audit procedure"),
"{}",
carried.body
);
}
#[tokio::test]
async fn a_bundled_file_is_served_by_the_tool_itself() {
let dir = std::env::temp_dir().join(format!("mecha-skill-l3-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("reference.md"), "the long reference").unwrap();
let mut s = skill("bundled", None);
s.dir = dir.clone();
let tool = SkillTool::new(vec![s]);
let out = tool
.call(
json!({"name": "bundled", "file": "reference.md"}),
&ToolCtx::default(),
)
.await
.unwrap();
assert!(!out.is_error, "{}", out.content);
assert_eq!(out.content, "the long reference");
assert!(
tool.loaded().is_empty(),
"reading a reference is not adopting the procedure"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn a_bundled_path_cannot_climb_out_of_its_skill() {
let dir = std::env::temp_dir().join(format!("mecha-skill-esc-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut s = skill("escape", None);
s.dir = dir.clone();
let tool = SkillTool::new(vec![s]);
for bad in ["../../../etc/passwd", "/etc/passwd"] {
let out = tool
.call(json!({"name": "escape", "file": bad}), &ToolCtx::default())
.await
.unwrap();
assert!(out.is_error, "should have refused {bad}: {}", out.content);
}
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn a_multibyte_reference_is_cut_on_a_character_boundary() {
let dir = std::env::temp_dir().join(format!("mecha-skill-utf8-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let big = "é".repeat(MAX_BUNDLED_BYTES); std::fs::write(dir.join("ref.md"), &big).unwrap();
let mut s = skill("utf8", None);
s.dir = dir.clone();
let tool = SkillTool::new(vec![s]);
let out = tool
.call(
json!({"name": "utf8", "file": "ref.md"}),
&ToolCtx::default(),
)
.await
.unwrap();
assert!(!out.is_error, "{}", out.content);
assert!(
out.content.contains("[cut:"),
"it really was over the ceiling"
);
assert!(
out.content.len() < big.len(),
"content {} vs original {}",
out.content.len(),
big.len()
);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn the_carried_block_is_bounded_and_names_what_would_not_fit() {
let long = "x".repeat(CARRIED_BUDGET * 2 / 3);
let mut a = skill("older", None);
a.body = long.clone();
let mut b = skill("newer", None);
b.body = long;
let tool = SkillTool::new(vec![a, b]);
load(&tool, "older").await;
load(&tool, "newer").await;
let carried = tool.carried_state(&ToolCtx::default()).unwrap();
assert!(
carried.body.len() < 2 * CARRIED_BUDGET,
"bounded: {}",
carried.body.len()
);
assert!(carried.body.contains("# Skill: newer"), "newest survives");
assert!(
carried.body.contains("too long to carry: older"),
"and the drop is named: {}",
carried.body
);
}
#[tokio::test]
async fn a_procedure_too_long_to_carry_is_named_rather_than_truncated() {
let mut huge = skill("huge", None);
huge.body = "x".repeat(CARRIED_BUDGET * 2);
let tool = SkillTool::new(vec![huge]);
load(&tool, "huge").await;
let carried = tool.carried_state(&ToolCtx::default()).unwrap();
assert!(
carried.body.contains("too long to carry: huge"),
"{}",
carried.body
);
assert!(
!carried.body.contains(&"x".repeat(100)),
"no half a procedure"
);
}
#[tokio::test]
async fn a_conversation_ending_unloads_everything() {
let tool = SkillTool::new(vec![skill("audit", Some(vec!["fs_read"]))]);
load(&tool, "audit").await;
assert!(tool.narrows_surface_to().is_some());
assert!(tool.carried_state(&ToolCtx::default()).is_some());
tool.forget_conversation_state();
assert!(tool.loaded().is_empty());
assert_eq!(
tool.narrows_surface_to(),
None,
"the surface has to come back, or the next task starts constrained"
);
assert!(tool.carried_state(&ToolCtx::default()).is_none());
}
#[tokio::test]
async fn a_skill_that_declares_no_tools_narrows_nothing() {
let tool = SkillTool::new(vec![skill("audit", None)]);
load(&tool, "audit").await;
assert_eq!(tool.narrows_surface_to(), None);
}
#[tokio::test]
async fn declared_tools_narrow_and_two_skills_union() {
let tool = SkillTool::new(vec![
skill("audit", Some(vec!["fs_read"])),
skill("brief", Some(vec!["mail_send"])),
]);
load(&tool, "audit").await;
assert_eq!(tool.narrows_surface_to().unwrap(), vec!["fs_read"]);
load(&tool, "brief").await;
let both = tool.narrows_surface_to().unwrap();
assert!(both.contains(&"fs_read".to_string()));
assert!(both.contains(&"mail_send".to_string()));
}
#[tokio::test]
async fn an_opinion_free_skill_does_not_widen_a_restriction_its_neighbour_set() {
let tool = SkillTool::new(vec![
skill("audit", Some(vec!["fs_read"])),
skill("plain", None),
]);
load(&tool, "audit").await;
load(&tool, "plain").await;
assert_eq!(tool.narrows_surface_to().unwrap(), vec!["fs_read"]);
}
}