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                watcher_exit_path: None,
324                tool_register_tx: None,
325                session_manager: None,
326                subagent_registry: None,
327                event_queue: None,
328                delegation_parent: None,
329                secret_prompt: None,
330                orchestration: None,
331                tool_activation: None,
332                mcp_leases: None,
333                extension_leases: None,
334                memory_context: None,
335            },
336            limits: crate::tools::ToolLimits {
337                max_tool_output: 30000,
338                max_tool_buffer: 256 * 1024,
339                bash_timeout: 30,
340                bash_max_timeout: 300,
341                subagent_timeout: 300,
342            },
343        }
344    }
345
346    fn mk(name: &str, plugin: Option<&str>) -> LoadedSkill {
347        LoadedSkill::new_inline(
348            name,
349            &format!("desc-{name}"),
350            &format!("body-{name}"),
351            plugin,
352            PathBuf::from("/"),
353            PathBuf::from("/SKILL.md"),
354        )
355    }
356
357    /// Review fix: `new_inline` is public and infallible, so hostile
358    /// inline metadata must be neutralized at the output boundary — the
359    /// formatted header is bounded and control-free, and Debug renders
360    /// neither body content, nor paths, nor body-derived byte lengths.
361    #[test]
362    fn hostile_inline_metadata_is_bounded_and_redacted() {
363        let s = LoadedSkill::new_inline(
364            &format!("evil\u{7}name\u{1b}[31m{}", "n".repeat(300)),
365            &format!("desc\r\nwith\u{0}controls{}", "d".repeat(2000)),
366            "SECRET_INLINE_BODY_4242",
367            None,
368            PathBuf::from("/secret/base/dir"),
369            PathBuf::from("/secret/source/SKILL.md"),
370        );
371        let out = LoadSkillTool::format_body(&s).unwrap();
372        let header = out.lines().next().unwrap();
373        assert!(header.starts_with("# Skill: "));
374        assert!(
375            !header.chars().any(char::is_control),
376            "header must be control-free"
377        );
378        assert!(!header.contains('\u{7}') && !header.contains('\u{1b}'));
379        assert!(
380            header.len() < 1300,
381            "header bounded: {} bytes",
382            header.len()
383        );
384        // Body still delivered after the sanitized header.
385        assert!(out.contains("SECRET_INLINE_BODY_4242"));
386
387        // Debug: no body, no byte length, no paths.
388        let rendered = format!("{s:?}");
389        assert!(!rendered.contains("SECRET_INLINE_BODY_4242"));
390        assert!(!rendered.contains("/secret/"));
391        assert!(!rendered.contains("bytes"), "no body-derived length");
392        assert!(!rendered.contains("23"), "no body length digits (23)");
393    }
394
395    #[test]
396    fn format_body_includes_name_and_description() {
397        let s = LoadedSkill::new_inline(
398            "x",
399            "y",
400            "z",
401            None,
402            PathBuf::from("/"),
403            PathBuf::from("/SKILL.md"),
404        );
405        let out = LoadSkillTool::format_body(&s).unwrap();
406        assert!(out.contains("x"));
407        assert!(out.contains("y"));
408        assert!(out.contains("z"));
409        assert!(out.contains("Follow these guidelines"));
410    }
411
412    #[tokio::test]
413    async fn execute_returns_skill_body_on_unique_match() {
414        use crate::Tool;
415        let reg = Arc::new(crate::skills::registry::CommandRegistry::new(
416            &[],
417            vec![mk("search", Some("p1"))],
418        ));
419        let tool = LoadSkillTool::new(reg);
420        let result = tool
421            .execute(serde_json::json!({"skill": "search"}), test_ctx())
422            .await
423            .unwrap();
424        assert!(result.contains("# Skill: search"));
425        assert!(result.contains("desc-search"));
426        assert!(result.contains("body-search"));
427    }
428
429    #[tokio::test]
430    async fn execute_errors_on_ambiguous() {
431        use crate::Tool;
432        let reg = Arc::new(crate::skills::registry::CommandRegistry::new(
433            &[],
434            vec![mk("search", Some("p1")), mk("search", Some("p2"))],
435        ));
436        let tool = LoadSkillTool::new(reg);
437        let err = tool
438            .execute(serde_json::json!({"skill": "search"}), test_ctx())
439            .await
440            .unwrap_err();
441        let msg = format!("{err}");
442        assert!(msg.contains("ambiguous"));
443        assert!(msg.contains("p1:search"));
444        assert!(msg.contains("p2:search"));
445    }
446
447    #[tokio::test]
448    async fn execute_errors_on_unknown() {
449        use crate::Tool;
450        let reg = Arc::new(crate::skills::registry::CommandRegistry::new(&[], vec![]));
451        let tool = LoadSkillTool::new(reg);
452        let err = tool
453            .execute(serde_json::json!({"skill": "nosuch"}), test_ctx())
454            .await
455            .unwrap_err();
456        assert!(format!("{err}").contains("unknown skill 'nosuch'"));
457    }
458
459    #[tokio::test]
460    async fn execute_errors_on_builtin() {
461        use crate::Tool;
462        // A built-in is not a skill; load_skill should refuse to load it.
463        let reg = Arc::new(crate::skills::registry::CommandRegistry::new(
464            &["clear"],
465            vec![],
466        ));
467        let tool = LoadSkillTool::new(reg);
468        let err = tool
469            .execute(serde_json::json!({"skill": "clear"}), test_ctx())
470            .await
471            .unwrap_err();
472        assert!(format!("{err}").contains("unknown skill 'clear'"));
473    }
474
475    #[tokio::test]
476    async fn execute_errors_on_missing_skill_param() {
477        use crate::Tool;
478        let reg = Arc::new(crate::skills::registry::CommandRegistry::new(&[], vec![]));
479        let tool = LoadSkillTool::new(reg);
480        let err = tool
481            .execute(serde_json::json!({}), test_ctx())
482            .await
483            .unwrap_err();
484        assert!(format!("{err}").contains("Missing 'skill' parameter"));
485    }
486
487    #[test]
488    fn parameters_schema_is_well_formed() {
489        use crate::Tool;
490        let reg = Arc::new(crate::skills::registry::CommandRegistry::new(&[], vec![]));
491        let tool = LoadSkillTool::new(reg);
492        let schema = tool.parameters();
493        assert_eq!(schema["type"], "object");
494        assert_eq!(schema["properties"]["skill"]["type"], "string");
495        assert_eq!(schema["required"], serde_json::json!(["skill"]));
496    }
497
498    /// Production-shaped: `skills::register` puts `load_skill` into the live
499    /// tool registry. As a compiled-in capability it must catalog as
500    /// `builtin:load_skill` with builtin-runtime provenance — never as an
501    /// unverified unknown.
502    #[test]
503    fn load_skill_catalogs_as_builtin_capability() {
504        use crate::tools::catalog::{CapabilitySource, ToolId, TrustProvenance};
505        let reg = Arc::new(crate::skills::registry::CommandRegistry::new(
506            &[],
507            vec![mk("search", Some("p1"))],
508        ));
509        let mut tools = crate::tools::ToolRegistry::empty();
510        tools.register(Arc::new(LoadSkillTool::new(reg)));
511
512        let record = tools
513            .catalog()
514            .get(&ToolId::builtin("load_skill"))
515            .expect("load_skill cataloged under the builtin namespace");
516        assert_eq!(record.source(), &CapabilitySource::Builtin);
517        assert_eq!(record.provenance(), &TrustProvenance::BuiltinRuntime);
518        assert!(
519            tools
520                .catalog()
521                .get(&ToolId::unclassified("load_skill"))
522                .is_none(),
523            "load_skill must not be cataloged as unknown"
524        );
525        // Registration/exposure behavior unchanged.
526        assert!(tools.get("load_skill").is_some());
527        assert_eq!(tools.tools_schema().len(), 1);
528    }
529}