1use super::{Capabilities, CarriedState, Tool, ToolCtx, ToolOutput};
30use crate::skill::Skill;
31use anyhow::Result;
32use async_trait::async_trait;
33use serde_json::{json, Value};
34use std::path::PathBuf;
35use std::sync::Mutex;
36
37pub struct SkillTool {
39 available: Vec<Skill>,
46 loaded: Mutex<Vec<String>>,
48}
49
50impl SkillTool {
51 pub fn new(available: Vec<Skill>) -> Self {
52 SkillTool {
53 available,
54 loaded: Mutex::new(Vec::new()),
55 }
56 }
57
58 pub fn loaded(&self) -> Vec<String> {
60 self.loaded.lock().unwrap().clone()
61 }
62
63 pub fn available(&self) -> &[Skill] {
72 &self.available
73 }
74
75 pub fn clear(&self) {
85 self.loaded.lock().unwrap().clear();
86 }
87
88 fn skill(&self, name: &str) -> Option<&Skill> {
89 self.available.iter().find(|s| s.name == name)
90 }
91
92 fn render(skill: &Skill) -> String {
102 format!(
103 "# Skill: {}\n\
104 If this procedure points at a file bundled with it, call `skill` \
105 again with `file` set to that name — the ordinary file tools \
106 cannot reach it, since a skill lives outside the workspace.\n\n{}",
107 skill.name, skill.body
108 )
109 }
110
111 fn resolve_bundled(skill: &Skill, file: &str) -> Result<PathBuf, String> {
119 let root = skill
120 .dir
121 .canonicalize()
122 .map_err(|e| format!("cannot read the skill's directory: {e}"))?;
123 let candidate = root.join(file);
124 let resolved = candidate
125 .canonicalize()
126 .map_err(|_| format!("no file `{file}` bundled with skill `{}`", skill.name))?;
127 if !resolved.starts_with(&root) {
128 return Err(format!(
129 "`{file}` resolves outside skill `{}` — a bundled file has to be \
130 inside the skill's own directory",
131 skill.name
132 ));
133 }
134 if !resolved.is_file() {
135 return Err(format!("`{file}` is not a file"));
136 }
137 Ok(resolved)
138 }
139}
140
141const MAX_BUNDLED_BYTES: usize = 60_000;
149
150const CARRIED_BUDGET: usize = 24_000;
157
158fn floor_char_boundary(s: &str, max: usize) -> usize {
163 if max >= s.len() {
164 return s.len();
165 }
166 let mut end = max;
167 while end > 0 && !s.is_char_boundary(end) {
168 end -= 1;
169 }
170 end
171}
172
173#[async_trait]
174impl Tool for SkillTool {
175 fn name(&self) -> &str {
176 "skill"
177 }
178
179 fn description(&self) -> &str {
180 "Load the full instructions for one of the skills listed in your system \
181 prompt. Call this before starting work the skill covers, then follow what \
182 it says. The skills are procedures the user wrote for you, so they are more \
183 specific than your general judgement about how to do the task."
184 }
185
186 fn input_schema(&self) -> Value {
187 json!({
188 "type": "object",
189 "properties": {
190 "name": {
191 "type": "string",
192 "description": "The skill's name, exactly as listed in the system prompt."
193 },
194 "file": {
195 "type": "string",
196 "description": "Optional: a file bundled with the skill, named by its procedure. Omit to load the procedure itself."
197 }
198 },
199 "required": ["name"]
200 })
201 }
202
203 fn read_only(&self) -> bool {
208 true
209 }
210
211 fn capabilities(&self) -> Capabilities {
212 Capabilities::default()
213 }
214
215 async fn call(&self, input: Value, _ctx: &ToolCtx) -> Result<ToolOutput> {
216 let Some(name) = input.get("name").and_then(Value::as_str) else {
217 return Ok(ToolOutput::err("`name` is required, and must be a string"));
218 };
219 let name = name.trim();
220
221 let Some(skill) = self.skill(name) else {
222 let known: Vec<&str> = self.available.iter().map(|s| s.name.as_str()).collect();
223 return Ok(ToolOutput::err(if known.is_empty() {
224 "no skills are enabled for this run".to_string()
225 } else {
226 format!("no skill named `{name}`. Enabled: {}", known.join(", "))
227 }));
228 };
229
230 if let Some(file) = input.get("file").and_then(Value::as_str) {
235 return Ok(match Self::resolve_bundled(skill, file.trim()) {
236 Err(why) => ToolOutput::err(why),
237 Ok(path) => match std::fs::read_to_string(&path) {
238 Err(e) => ToolOutput::err(format!("cannot read `{file}`: {e}")),
239 Ok(text) if text.len() > MAX_BUNDLED_BYTES => {
240 let end = floor_char_boundary(&text, MAX_BUNDLED_BYTES);
246 ToolOutput::ok(format!(
247 "{}\n\n[cut: `{file}` is {} bytes, over the {MAX_BUNDLED_BYTES}-byte \
248 ceiling for one bundled file]",
249 &text[..end],
250 text.len()
251 ))
252 }
253 Ok(text) => ToolOutput::ok(text),
254 },
255 });
256 }
257
258 let mut loaded = self.loaded.lock().unwrap();
259 if !loaded.iter().any(|n| n == name) {
260 loaded.push(name.to_string());
261 }
262 drop(loaded);
263
264 Ok(ToolOutput::ok(Self::render(skill)))
269 }
270
271 fn carried_state(&self) -> Option<CarriedState> {
281 let loaded = self.loaded.lock().unwrap();
282 if loaded.is_empty() {
283 return None;
284 }
285 let mut kept: Vec<String> = Vec::new();
296 let mut dropped: Vec<&str> = Vec::new();
297 let mut budget = CARRIED_BUDGET;
298 for skill in loaded.iter().rev().filter_map(|n| self.skill(n)) {
299 let rendered = Self::render(skill);
300 if rendered.len() <= budget {
301 budget -= rendered.len();
302 kept.push(rendered);
303 } else {
304 dropped.push(skill.name.as_str());
305 }
306 }
307 if kept.is_empty() && dropped.is_empty() {
308 return None;
309 }
310 kept.reverse();
311 dropped.reverse();
312
313 let mut body = format!(
314 "Skills loaded in this session, reproduced in full because a \
315 summary of a procedure is a different procedure:\n\n{}",
316 kept.join("\n\n---\n\n")
317 );
318 if !dropped.is_empty() {
319 body.push_str(&format!(
320 "\n\n[also loaded earlier, too long to carry: {}. Call `skill` again if \
321 you need one of them.]",
322 dropped.join(", ")
323 ));
324 }
325 Some(CarriedState {
326 label: "skill".to_string(),
327 body,
328 })
329 }
330
331 fn forget_conversation_state(&self) {
341 self.clear();
342 }
343
344 fn narrows_surface_to(&self) -> Option<Vec<String>> {
345 let loaded = self.loaded.lock().unwrap();
346 let mut names: Vec<String> = Vec::new();
347 let mut any = false;
348 for skill in loaded.iter().filter_map(|n| self.skill(n)) {
349 if let Some(tools) = &skill.tools {
350 any = true;
351 names.extend(tools.iter().cloned());
352 }
353 }
354 any.then_some(names)
355 }
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361 use std::path::PathBuf;
362
363 fn skill(name: &str, tools: Option<Vec<&str>>) -> Skill {
364 Skill {
365 name: name.to_string(),
366 description: "d".into(),
367 triggers: Vec::new(),
368 tools: tools.map(|t| t.into_iter().map(String::from).collect()),
369 body: format!("the {name} procedure"),
370 dir: PathBuf::from("/tmp/skills").join(name),
371 }
372 }
373
374 async fn load(tool: &SkillTool, name: &str) -> ToolOutput {
375 tool.call(json!({ "name": name }), &ToolCtx::default())
376 .await
377 .unwrap()
378 }
379
380 #[tokio::test]
381 async fn loading_returns_the_body_verbatim_and_names_the_directory() {
382 let tool = SkillTool::new(vec![skill("audit", None)]);
383 let out = load(&tool, "audit").await;
384 assert!(!out.is_error);
385 assert!(
386 out.content.contains("the audit procedure"),
387 "{}",
388 out.content
389 );
390 assert!(
391 out.content.contains("call `skill` again with `file`"),
392 "level 3 has to be reachable, and only through this tool: {}",
393 out.content
394 );
395 assert_eq!(tool.loaded(), vec!["audit"]);
396 }
397
398 #[tokio::test]
399 async fn a_loaded_skill_is_never_third_party_content() {
400 let tool = SkillTool::new(vec![skill("audit", None)]);
403 let out = load(&tool, "audit").await;
404 assert!(
405 !out.external,
406 "a user-authored procedure is not outside input"
407 );
408 assert_eq!(tool.capabilities(), Capabilities::default());
409 }
410
411 #[tokio::test]
412 async fn an_unknown_name_lists_what_is_enabled_rather_than_failing_blind() {
413 let tool = SkillTool::new(vec![skill("audit", None), skill("brief", None)]);
414 let out = load(&tool, "audi").await;
415 assert!(out.is_error);
416 assert!(out.content.contains("audit") && out.content.contains("brief"));
417 assert!(tool.loaded().is_empty(), "a failed load is not a load");
418 }
419
420 #[tokio::test]
421 async fn re_loading_hands_the_body_back_rather_than_declining() {
422 let tool = SkillTool::new(vec![skill("audit", None)]);
424 let first = load(&tool, "audit").await;
425 let again = load(&tool, "audit").await;
426 assert_eq!(first.content, again.content);
427 assert_eq!(tool.loaded(), vec!["audit"], "and it is not counted twice");
428 }
429
430 #[tokio::test]
431 async fn nothing_is_carried_across_a_compaction_until_something_is_loaded() {
432 let tool = SkillTool::new(vec![skill("audit", None)]);
433 assert!(tool.carried_state().is_none());
434 load(&tool, "audit").await;
435 let carried = tool.carried_state().unwrap();
436 assert!(
437 carried.body.contains("the audit procedure"),
438 "{}",
439 carried.body
440 );
441 }
442
443 #[tokio::test]
444 async fn a_bundled_file_is_served_by_the_tool_itself() {
445 let dir = std::env::temp_dir().join(format!("mecha-skill-l3-{}", std::process::id()));
449 std::fs::create_dir_all(&dir).unwrap();
450 std::fs::write(dir.join("reference.md"), "the long reference").unwrap();
451 let mut s = skill("bundled", None);
452 s.dir = dir.clone();
453 let tool = SkillTool::new(vec![s]);
454
455 let out = tool
456 .call(
457 json!({"name": "bundled", "file": "reference.md"}),
458 &ToolCtx::default(),
459 )
460 .await
461 .unwrap();
462 assert!(!out.is_error, "{}", out.content);
463 assert_eq!(out.content, "the long reference");
464 assert!(
465 tool.loaded().is_empty(),
466 "reading a reference is not adopting the procedure"
467 );
468
469 let _ = std::fs::remove_dir_all(&dir);
470 }
471
472 #[tokio::test]
473 async fn a_bundled_path_cannot_climb_out_of_its_skill() {
474 let dir = std::env::temp_dir().join(format!("mecha-skill-esc-{}", std::process::id()));
477 std::fs::create_dir_all(&dir).unwrap();
478 let mut s = skill("escape", None);
479 s.dir = dir.clone();
480 let tool = SkillTool::new(vec![s]);
481
482 for bad in ["../../../etc/passwd", "/etc/passwd"] {
483 let out = tool
484 .call(json!({"name": "escape", "file": bad}), &ToolCtx::default())
485 .await
486 .unwrap();
487 assert!(out.is_error, "should have refused {bad}: {}", out.content);
488 }
489
490 let _ = std::fs::remove_dir_all(&dir);
491 }
492
493 #[tokio::test]
494 async fn a_multibyte_reference_is_cut_on_a_character_boundary() {
495 let dir = std::env::temp_dir().join(format!("mecha-skill-utf8-{}", std::process::id()));
499 std::fs::create_dir_all(&dir).unwrap();
500 let big = "é".repeat(MAX_BUNDLED_BYTES); std::fs::write(dir.join("ref.md"), &big).unwrap();
502 let mut s = skill("utf8", None);
503 s.dir = dir.clone();
504 let tool = SkillTool::new(vec![s]);
505
506 let out = tool
507 .call(
508 json!({"name": "utf8", "file": "ref.md"}),
509 &ToolCtx::default(),
510 )
511 .await
512 .unwrap();
513 assert!(!out.is_error, "{}", out.content);
514 assert!(
515 out.content.contains("[cut:"),
516 "it really was over the ceiling"
517 );
518 assert!(
520 out.content.len() < big.len(),
521 "content {} vs original {}",
522 out.content.len(),
523 big.len()
524 );
525
526 let _ = std::fs::remove_dir_all(&dir);
527 }
528
529 #[tokio::test]
530 async fn the_carried_block_is_bounded_and_names_what_would_not_fit() {
531 let long = "x".repeat(CARRIED_BUDGET * 2 / 3);
536 let mut a = skill("older", None);
537 a.body = long.clone();
538 let mut b = skill("newer", None);
539 b.body = long;
540 let tool = SkillTool::new(vec![a, b]);
541 load(&tool, "older").await;
542 load(&tool, "newer").await;
543
544 let carried = tool.carried_state().unwrap();
545 assert!(
546 carried.body.len() < 2 * CARRIED_BUDGET,
547 "bounded: {}",
548 carried.body.len()
549 );
550 assert!(carried.body.contains("# Skill: newer"), "newest survives");
553 assert!(
554 carried.body.contains("too long to carry: older"),
555 "and the drop is named: {}",
556 carried.body
557 );
558 }
559
560 #[tokio::test]
561 async fn a_procedure_too_long_to_carry_is_named_rather_than_truncated() {
562 let mut huge = skill("huge", None);
566 huge.body = "x".repeat(CARRIED_BUDGET * 2);
567 let tool = SkillTool::new(vec![huge]);
568 load(&tool, "huge").await;
569
570 let carried = tool.carried_state().unwrap();
571 assert!(
572 carried.body.contains("too long to carry: huge"),
573 "{}",
574 carried.body
575 );
576 assert!(
577 !carried.body.contains(&"x".repeat(100)),
578 "no half a procedure"
579 );
580 }
581
582 #[tokio::test]
583 async fn a_conversation_ending_unloads_everything() {
584 let tool = SkillTool::new(vec![skill("audit", Some(vec!["fs_read"]))]);
587 load(&tool, "audit").await;
588 assert!(tool.narrows_surface_to().is_some());
589 assert!(tool.carried_state().is_some());
590
591 tool.forget_conversation_state();
592 assert!(tool.loaded().is_empty());
593 assert_eq!(
594 tool.narrows_surface_to(),
595 None,
596 "the surface has to come back, or the next task starts constrained"
597 );
598 assert!(tool.carried_state().is_none());
599 }
600
601 #[tokio::test]
602 async fn a_skill_that_declares_no_tools_narrows_nothing() {
603 let tool = SkillTool::new(vec![skill("audit", None)]);
604 load(&tool, "audit").await;
605 assert_eq!(tool.narrows_surface_to(), None);
606 }
607
608 #[tokio::test]
609 async fn declared_tools_narrow_and_two_skills_union() {
610 let tool = SkillTool::new(vec![
611 skill("audit", Some(vec!["fs_read"])),
612 skill("brief", Some(vec!["mail_send"])),
613 ]);
614 load(&tool, "audit").await;
615 assert_eq!(tool.narrows_surface_to().unwrap(), vec!["fs_read"]);
616
617 load(&tool, "brief").await;
620 let both = tool.narrows_surface_to().unwrap();
621 assert!(both.contains(&"fs_read".to_string()));
622 assert!(both.contains(&"mail_send".to_string()));
623 }
624
625 #[tokio::test]
626 async fn an_opinion_free_skill_does_not_widen_a_restriction_its_neighbour_set() {
627 let tool = SkillTool::new(vec![
630 skill("audit", Some(vec!["fs_read"])),
631 skill("plain", None),
632 ]);
633 load(&tool, "audit").await;
634 load(&tool, "plain").await;
635 assert_eq!(tool.narrows_surface_to().unwrap(), vec!["fs_read"]);
636 }
637}