Skip to main content

agent_engine/skills/
tool.rs

1//! `load_skill` tool — model-initiated skill activation — plus the Task 17
2//! `search_skills` bounded discovery tool (stable skill IDs + compact
3//! descriptions only; no bodies, no source paths, no process/network).
4
5use crate::skills::{
6    looks_like_stable_skill_id,
7    registry::{CommandRegistry, Resolution},
8    stable_skill_id, LoadedSkill,
9};
10use serde_json::json;
11use std::sync::Arc;
12
13pub struct LoadSkillTool {
14    registry: Arc<CommandRegistry>,
15}
16
17impl LoadSkillTool {
18    pub fn new(registry: Arc<CommandRegistry>) -> Self {
19        Self { registry }
20    }
21
22    /// Produce the tool-result body for a successfully loaded skill.
23    /// Shared between user-initiated (slash) and model-initiated (tool)
24    /// paths. Task 21: THIS is the single point where the body is read
25    /// from disk, fingerprint-verified, and substituted — it can
26    /// therefore fail closed with a static, path-free reason.
27    pub fn format_body(skill: &LoadedSkill) -> Result<String, String> {
28        let body = skill.load_body().map_err(|reason| {
29            format!(
30                "skill '{}' could not be loaded: {reason}",
31                crate::BoundedText::new(&skill.name, 128).text
32            )
33        })?;
34        // Header fields are bounded AND control-sanitized here: discovery
35        // enforces these bounds for lazy skills, but `new_inline` is a
36        // public infallible constructor, so the header must not trust its
37        // metadata.
38        Ok(format!(
39            "# Skill: {} — {}\n\nFollow these guidelines for the rest of this conversation.\n\n{}",
40            sanitize_header_field(&skill.name, 128),
41            sanitize_header_field(&skill.description, 1024),
42            body
43        ))
44    }
45}
46
47/// Bounded, control-free projection of a header metadata field: control
48/// characters (which could forge markdown/ANSI structure in the tool
49/// result header) become spaces; the result is UTF-8-safe truncated.
50fn sanitize_header_field(raw: &str, max_bytes: usize) -> String {
51    let cleaned: String = raw
52        .chars()
53        .map(|c| if c.is_control() { ' ' } else { c })
54        .collect();
55    crate::BoundedText::new(&cleaned, max_bytes).text
56}
57
58#[async_trait::async_trait]
59impl crate::Tool for LoadSkillTool {
60    fn effect(&self) -> crate::tools::catalog::ToolEffect {
61        crate::tools::catalog::ToolEffect::ReadOnly
62    }
63
64    fn name(&self) -> &str {
65        "load_skill"
66    }
67
68    fn description(&self) -> &str {
69        "Load a skill to guide your behavior for the current conversation. \
70         Skills provide structured guidelines, checklists, and best practices. \
71         Call this when a task would benefit from a specific methodology."
72    }
73
74    /// Compiled into this runtime: `skills::register` adds this tool from
75    /// core code, so its catalog identity is `builtin:load_skill` with
76    /// builtin-runtime provenance — never a conservative unknown.
77    fn origin(&self) -> crate::tools::ToolOrigin {
78        crate::tools::ToolOrigin::Builtin
79    }
80
81    /// CONSTANT schema (Task 21): the description never enumerates the
82    /// catalog, so the first request cannot grow with installed skills.
83    /// Discovery routes through `search_skills`; execution still accepts
84    /// legacy bare and `plugin:skill` spellings plus stable ids.
85    fn parameters(&self) -> serde_json::Value {
86        json!({
87            "type": "object",
88            "properties": {
89                "skill": {
90                    "type": "string",
91                    "description": "Skill to load: a stable id from search_skills, a bare name, or plugin:skill."
92                }
93            },
94            "required": ["skill"]
95        })
96    }
97
98    async fn execute(
99        &self,
100        params: serde_json::Value,
101        _ctx: crate::ToolContext,
102    ) -> crate::Result<String> {
103        let name = params["skill"]
104            .as_str()
105            .ok_or_else(|| crate::RuntimeError::Tool("Missing 'skill' parameter".to_string()))?;
106
107        // Stable-ID path first (Task 17): inputs spelled in the reserved
108        // `skill:`/`skill.<plugin>:` namespace resolve by EXACT deterministic
109        // id. Exactly one match loads that skill and nothing else; zero
110        // matches fail typed; duplicate ids fail closed as ambiguous rather
111        // than guessing. A legacy plugin literally named `skill`
112        // (`skill:foo` qualified) keeps resolving when it is the ONLY
113        // interpretation; when the same spelling also denotes a stable
114        // loose id, neither interpretation wins — fail closed as ambiguous.
115        if looks_like_stable_skill_id(name) {
116            let matches: Vec<Arc<LoadedSkill>> = self
117                .registry
118                .all_skills()
119                .into_iter()
120                .filter(|s| stable_skill_id(s) == name)
121                .collect();
122            // Legacy plugin-qualified reading of the SAME raw spelling
123            // (a plugin whose name begins the reserved namespace). This is
124            // always a different skill than any stable match: stable loose
125            // ids have no plugin, and stable plugin ids never share their
126            // plugin's raw spelling with the `skill`/`skill.*` prefix.
127            let legacy: Option<Arc<LoadedSkill>> = match self.registry.resolve(name) {
128                Resolution::Skill(s) => Some(s),
129                _ => None,
130            };
131            if let Some(legacy) = &legacy {
132                if !matches.is_empty() {
133                    return Err(crate::RuntimeError::Tool(format!(
134                        "ambiguous skill id '{}': it names both a stable skill id and a \
135                         plugin-qualified skill; use '{}' for the plugin skill",
136                        crate::BoundedText::new(name, 160).text,
137                        stable_skill_id(legacy)
138                    )));
139                }
140                return Self::format_body(legacy).map_err(crate::RuntimeError::Tool);
141            }
142            return match matches.as_slice() {
143                [] => Err(crate::RuntimeError::Tool(format!(
144                    "unknown skill id '{}'",
145                    crate::BoundedText::new(name, 160).text
146                ))),
147                [skill] => Self::format_body(skill).map_err(crate::RuntimeError::Tool),
148                _ => Err(crate::RuntimeError::Tool(format!(
149                    "ambiguous skill id '{}'",
150                    crate::BoundedText::new(name, 160).text
151                ))),
152            };
153        }
154
155        match self.registry.resolve(name) {
156            Resolution::Skill(s) => Self::format_body(&s).map_err(crate::RuntimeError::Tool),
157            Resolution::Ambiguous(opts) => {
158                // Bound the echoed request and every offered option; the
159                // joined list is additionally capped as a whole.
160                let bounded: Vec<String> = opts
161                    .iter()
162                    .map(|opt| crate::BoundedText::new(opt, 128).text)
163                    .collect();
164                let list = crate::BoundedText::new(&bounded.join(", "), 1024).text;
165                Err(crate::RuntimeError::Tool(format!(
166                    "ambiguous skill '{}'; specify one of: {}",
167                    crate::BoundedText::new(name, 128).text,
168                    list
169                )))
170            }
171            Resolution::PluginCommand(_) | Resolution::Builtin | Resolution::Unknown => {
172                Err(crate::RuntimeError::Tool(format!(
173                    "unknown skill '{}'",
174                    crate::BoundedText::new(name, 128).text
175                )))
176            }
177        }
178    }
179}
180
181// ── search_skills (Task 17) ─────────────────────────────────────────────────
182
183/// Byte budget for one compact skill description in search results.
184const SKILL_DESCRIPTION_MAX_BYTES: usize = 160;
185/// Maximum number of skills returned by one search.
186const SEARCH_SKILLS_MAX_RESULTS: usize = 16;
187/// Serialized byte budget for the returned skill collection.
188const SEARCH_SKILLS_MAX_RESULT_BYTES: usize = 8 * 1024;
189
190/// Bounded, deterministic skill discovery: stable skill IDs plus compact
191/// bounded descriptions ONLY. Never returns bodies or source paths, starts
192/// no process, performs no network access, and reads only the in-memory
193/// registry snapshot.
194pub struct SearchSkillsTool {
195    registry: Arc<CommandRegistry>,
196}
197
198impl SearchSkillsTool {
199    pub fn new(registry: Arc<CommandRegistry>) -> Self {
200        Self { registry }
201    }
202}
203
204#[async_trait::async_trait]
205impl crate::Tool for SearchSkillsTool {
206    fn effect(&self) -> crate::tools::catalog::ToolEffect {
207        crate::tools::catalog::ToolEffect::ReadOnly
208    }
209
210    fn name(&self) -> &str {
211        "search_skills"
212    }
213
214    fn description(&self) -> &str {
215        "Search available skills by keyword. Returns bounded stable skill \
216         ids with compact descriptions. Pass a returned id to load_skill to \
217         load exactly that skill."
218    }
219
220    /// Compiled into this runtime and registered by `skills::register`.
221    fn origin(&self) -> crate::tools::ToolOrigin {
222        crate::tools::ToolOrigin::Builtin
223    }
224
225    fn parameters(&self) -> serde_json::Value {
226        json!({
227            "type": "object",
228            "properties": {
229                "query": {
230                    "type": "string",
231                    "description": "Case-insensitive keyword matched against skill ids, names, and descriptions."
232                }
233            },
234            "required": ["query"]
235        })
236    }
237
238    async fn execute(
239        &self,
240        params: serde_json::Value,
241        _ctx: crate::ToolContext,
242    ) -> crate::Result<String> {
243        let raw = params["query"].as_str().ok_or_else(|| {
244            crate::RuntimeError::Tool("Missing 'query' parameter (string)".to_string())
245        })?;
246        // Reuse the discovery-query boundary parser: empty, oversized, and
247        // control-character queries fail typed and bounded.
248        let query = crate::tools::catalog::DiscoveryQuery::parse(raw)
249            .map_err(|err| crate::RuntimeError::Tool(format!("invalid skill query: {err}")))?;
250
251        // Deterministic candidate order: stable id, then plugin/name for
252        // (unexpected) duplicate ids.
253        let mut candidates: Vec<(String, Arc<LoadedSkill>)> = self
254            .registry
255            .all_skills()
256            .into_iter()
257            .map(|s| (stable_skill_id(&s), s))
258            .collect();
259        candidates.sort_by(|a, b| {
260            a.0.cmp(&b.0)
261                .then_with(|| a.1.plugin.cmp(&b.1.plugin))
262                .then_with(|| a.1.name.cmp(&b.1.name))
263        });
264
265        let mut hits = Vec::new();
266        // Serialized cost of `hits` as a compact JSON array: `[]` is 2
267        // bytes; each entry adds its own bytes plus a separating comma.
268        let mut used_bytes = 2usize;
269        let mut truncated = false;
270        for (id, skill) in candidates {
271            let description =
272                crate::BoundedText::new(&skill.description, SKILL_DESCRIPTION_MAX_BYTES).text;
273            let haystack = format!(
274                "{}\n{}\n{}",
275                id.to_lowercase(),
276                skill.name.to_lowercase(),
277                description.to_lowercase()
278            );
279            if !haystack.contains(query.needle()) {
280                continue;
281            }
282            let entry = json!({
283                "id": id,
284                "name": skill.name,
285                "description": description,
286            });
287            let entry_bytes = serde_json::to_vec(&entry)
288                .map_err(|err| {
289                    crate::RuntimeError::Tool(format!("failed to serialize skill entry: {err}"))
290                })?
291                .len();
292            let separator = usize::from(!hits.is_empty());
293            let next_bytes = used_bytes + separator + entry_bytes;
294            if hits.len() == SEARCH_SKILLS_MAX_RESULTS
295                || next_bytes > SEARCH_SKILLS_MAX_RESULT_BYTES
296            {
297                truncated = true;
298                break;
299            }
300            used_bytes = next_bytes;
301            hits.push(entry);
302        }
303
304        let body = json!({"truncated": truncated, "skills": hits});
305        serde_json::to_string(&body).map_err(|err| {
306            crate::RuntimeError::Tool(format!("failed to serialize skill search results: {err}"))
307        })
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314    use std::path::PathBuf;
315
316    fn test_ctx() -> crate::ToolContext {
317        crate::ToolContext {
318            channels: crate::tools::ToolChannels {
319                tx_delta: None,
320                tx_events: None,
321            },
322            capabilities: crate::tools::ToolCapabilities {
323                launch_cancel: None,
324                memory_backend: None,
325                watcher_exit_path: None,
326                tool_register_tx: None,
327                session_manager: None,
328                subagent_registry: None,
329                event_queue: None,
330                delegation_parent: None,
331                codex_parent_plan: None,
332                secret_prompt: None,
333                orchestration: None,
334                tool_activation: None,
335                mcp_leases: None,
336                extension_leases: None,
337                memory_context: None,
338                cwd: None,
339                env: None,
340                env_stripped: Vec::new(),
341                env_warned: Default::default(),
342            },
343            limits: crate::tools::ToolLimits {
344                max_tool_output: 30000,
345                max_tool_buffer: 256 * 1024,
346                bash_timeout: 30,
347                bash_max_timeout: 300,
348                subagent_timeout: 300,
349            },
350        }
351    }
352
353    fn mk(name: &str, plugin: Option<&str>) -> LoadedSkill {
354        LoadedSkill::new_inline(
355            name,
356            &format!("desc-{name}"),
357            &format!("body-{name}"),
358            plugin,
359            PathBuf::from("/"),
360            PathBuf::from("/SKILL.md"),
361        )
362    }
363
364    /// Review fix: `new_inline` is public and infallible, so hostile
365    /// inline metadata must be neutralized at the output boundary — the
366    /// formatted header is bounded and control-free, and Debug renders
367    /// neither body content, nor paths, nor body-derived byte lengths.
368    #[test]
369    fn hostile_inline_metadata_is_bounded_and_redacted() {
370        let s = LoadedSkill::new_inline(
371            &format!("evil\u{7}name\u{1b}[31m{}", "n".repeat(300)),
372            &format!("desc\r\nwith\u{0}controls{}", "d".repeat(2000)),
373            "SECRET_INLINE_BODY_4242",
374            None,
375            PathBuf::from("/secret/base/dir"),
376            PathBuf::from("/secret/source/SKILL.md"),
377        );
378        let out = LoadSkillTool::format_body(&s).unwrap();
379        let header = out.lines().next().unwrap();
380        assert!(header.starts_with("# Skill: "));
381        assert!(
382            !header.chars().any(char::is_control),
383            "header must be control-free"
384        );
385        assert!(!header.contains('\u{7}') && !header.contains('\u{1b}'));
386        assert!(
387            header.len() < 1300,
388            "header bounded: {} bytes",
389            header.len()
390        );
391        // Body still delivered after the sanitized header.
392        assert!(out.contains("SECRET_INLINE_BODY_4242"));
393
394        // Debug: no body, no byte length, no paths.
395        let rendered = format!("{s:?}");
396        assert!(!rendered.contains("SECRET_INLINE_BODY_4242"));
397        assert!(!rendered.contains("/secret/"));
398        assert!(!rendered.contains("bytes"), "no body-derived length");
399        assert!(!rendered.contains("23"), "no body length digits (23)");
400    }
401
402    #[test]
403    fn format_body_includes_name_and_description() {
404        let s = LoadedSkill::new_inline(
405            "x",
406            "y",
407            "z",
408            None,
409            PathBuf::from("/"),
410            PathBuf::from("/SKILL.md"),
411        );
412        let out = LoadSkillTool::format_body(&s).unwrap();
413        assert!(out.contains("x"));
414        assert!(out.contains("y"));
415        assert!(out.contains("z"));
416        assert!(out.contains("Follow these guidelines"));
417    }
418
419    #[tokio::test]
420    async fn execute_returns_skill_body_on_unique_match() {
421        use crate::Tool;
422        let reg = Arc::new(crate::skills::registry::CommandRegistry::new(
423            &[],
424            vec![mk("search", Some("p1"))],
425        ));
426        let tool = LoadSkillTool::new(reg);
427        let result = tool
428            .execute(serde_json::json!({"skill": "search"}), test_ctx())
429            .await
430            .unwrap();
431        assert!(result.contains("# Skill: search"));
432        assert!(result.contains("desc-search"));
433        assert!(result.contains("body-search"));
434    }
435
436    #[tokio::test]
437    async fn execute_errors_on_ambiguous() {
438        use crate::Tool;
439        let reg = Arc::new(crate::skills::registry::CommandRegistry::new(
440            &[],
441            vec![mk("search", Some("p1")), mk("search", Some("p2"))],
442        ));
443        let tool = LoadSkillTool::new(reg);
444        let err = tool
445            .execute(serde_json::json!({"skill": "search"}), test_ctx())
446            .await
447            .unwrap_err();
448        let msg = format!("{err}");
449        assert!(msg.contains("ambiguous"));
450        assert!(msg.contains("p1:search"));
451        assert!(msg.contains("p2:search"));
452    }
453
454    #[tokio::test]
455    async fn execute_errors_on_unknown() {
456        use crate::Tool;
457        let reg = Arc::new(crate::skills::registry::CommandRegistry::new(&[], vec![]));
458        let tool = LoadSkillTool::new(reg);
459        let err = tool
460            .execute(serde_json::json!({"skill": "nosuch"}), test_ctx())
461            .await
462            .unwrap_err();
463        assert!(format!("{err}").contains("unknown skill 'nosuch'"));
464    }
465
466    #[tokio::test]
467    async fn execute_errors_on_builtin() {
468        use crate::Tool;
469        // A built-in is not a skill; load_skill should refuse to load it.
470        let reg = Arc::new(crate::skills::registry::CommandRegistry::new(
471            &["clear"],
472            vec![],
473        ));
474        let tool = LoadSkillTool::new(reg);
475        let err = tool
476            .execute(serde_json::json!({"skill": "clear"}), test_ctx())
477            .await
478            .unwrap_err();
479        assert!(format!("{err}").contains("unknown skill 'clear'"));
480    }
481
482    #[tokio::test]
483    async fn execute_errors_on_missing_skill_param() {
484        use crate::Tool;
485        let reg = Arc::new(crate::skills::registry::CommandRegistry::new(&[], vec![]));
486        let tool = LoadSkillTool::new(reg);
487        let err = tool
488            .execute(serde_json::json!({}), test_ctx())
489            .await
490            .unwrap_err();
491        assert!(format!("{err}").contains("Missing 'skill' parameter"));
492    }
493
494    #[test]
495    fn parameters_schema_is_well_formed() {
496        use crate::Tool;
497        let reg = Arc::new(crate::skills::registry::CommandRegistry::new(&[], vec![]));
498        let tool = LoadSkillTool::new(reg);
499        let schema = tool.parameters();
500        assert_eq!(schema["type"], "object");
501        assert_eq!(schema["properties"]["skill"]["type"], "string");
502        assert_eq!(schema["required"], serde_json::json!(["skill"]));
503    }
504
505    /// Production-shaped: `skills::register` puts `load_skill` into the live
506    /// tool registry. As a compiled-in capability it must catalog as
507    /// `builtin:load_skill` with builtin-runtime provenance — never as an
508    /// unverified unknown.
509    #[test]
510    fn load_skill_catalogs_as_builtin_capability() {
511        use crate::tools::catalog::{CapabilitySource, ToolId, TrustProvenance};
512        let reg = Arc::new(crate::skills::registry::CommandRegistry::new(
513            &[],
514            vec![mk("search", Some("p1"))],
515        ));
516        let mut tools = crate::tools::ToolRegistry::empty();
517        tools.register(Arc::new(LoadSkillTool::new(reg)));
518
519        let record = tools
520            .catalog()
521            .get(&ToolId::builtin("load_skill"))
522            .expect("load_skill cataloged under the builtin namespace");
523        assert_eq!(record.source(), &CapabilitySource::Builtin);
524        assert_eq!(record.provenance(), &TrustProvenance::BuiltinRuntime);
525        assert!(
526            tools
527                .catalog()
528                .get(&ToolId::unclassified("load_skill"))
529                .is_none(),
530            "load_skill must not be cataloged as unknown"
531        );
532        // Registration/exposure behavior unchanged.
533        assert!(tools.get("load_skill").is_some());
534        assert_eq!(tools.tools_schema().len(), 1);
535    }
536}