tirea_extension_skills/
discovery_plugin.rs

1use crate::{SkillMeta, SkillRegistry, SkillState, SKILLS_DISCOVERY_PLUGIN_ID};
2use async_trait::async_trait;
3use std::collections::HashSet;
4use std::sync::Arc;
5use tirea_contract::runtime::behavior::{AgentBehavior, ReadOnlyContext};
6use tirea_contract::runtime::phase::{ActionSet, BeforeInferenceAction};
7use tirea_contract::scope::{is_scope_allowed, ScopeDomain};
8
9/// Injects a skills catalog into the LLM context so the model can discover and activate skills.
10///
11/// This is intentionally non-persistent: the catalog is rebuilt from the registry snapshot per step.
12#[derive(Clone)]
13pub struct SkillDiscoveryPlugin {
14    registry: Arc<dyn SkillRegistry>,
15    max_entries: usize,
16    max_chars: usize,
17}
18
19impl std::fmt::Debug for SkillDiscoveryPlugin {
20    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21        f.debug_struct("SkillDiscoveryPlugin")
22            .field("max_entries", &self.max_entries)
23            .field("max_chars", &self.max_chars)
24            .finish_non_exhaustive()
25    }
26}
27
28impl SkillDiscoveryPlugin {
29    pub fn new(registry: Arc<dyn SkillRegistry>) -> Self {
30        Self {
31            registry,
32            max_entries: 32,
33            max_chars: 16 * 1024,
34        }
35    }
36
37    pub fn with_limits(mut self, max_entries: usize, max_chars: usize) -> Self {
38        self.max_entries = max_entries.max(1);
39        self.max_chars = max_chars.max(256);
40        self
41    }
42
43    fn escape_text(s: &str) -> String {
44        s.replace('&', "&amp;")
45            .replace('<', "&lt;")
46            .replace('>', "&gt;")
47    }
48
49    fn render_catalog(
50        &self,
51        _active: &HashSet<String>,
52        policy: Option<&tirea_contract::runtime::RunPolicy>,
53    ) -> String {
54        let mut metas: Vec<SkillMeta> = self
55            .registry
56            .snapshot()
57            .values()
58            .map(|s| s.meta().clone())
59            .filter(|m| is_scope_allowed(policy, &m.id, ScopeDomain::Skill))
60            .collect();
61
62        if metas.is_empty() {
63            return String::new();
64        }
65
66        metas.sort_by(|a, b| a.id.cmp(&b.id));
67
68        let total = metas.len();
69        let mut out = String::new();
70        out.push_str("<available_skills>\n");
71
72        let mut shown = 0usize;
73        for m in metas.into_iter().take(self.max_entries) {
74            let id = Self::escape_text(&m.id);
75            let mut desc = m.description.clone();
76            if m.name != m.id && !m.name.trim().is_empty() {
77                if desc.trim().is_empty() {
78                    desc = m.name.clone();
79                } else {
80                    desc = format!("{}: {}", m.name.trim(), desc.trim());
81                }
82            }
83            let desc = Self::escape_text(&desc);
84
85            out.push_str("<skill>\n");
86            out.push_str(&format!("<name>{}</name>\n", id));
87            if !desc.trim().is_empty() {
88                out.push_str(&format!("<description>{}</description>\n", desc));
89            }
90            out.push_str("</skill>\n");
91            shown += 1;
92
93            if out.len() >= self.max_chars {
94                break;
95            }
96        }
97
98        out.push_str("</available_skills>\n");
99
100        if shown < total {
101            out.push_str(&format!(
102                "Note: available_skills truncated (total={}, shown={}).\n",
103                total, shown
104            ));
105        }
106
107        out.push_str("<skills_usage>\n");
108        out.push_str("If a listed skill is relevant, call tool \"skill\" with {\"skill\": \"<id or name>\"} before answering.\n");
109        out.push_str("Skill resources are not auto-loaded: use \"load_skill_resource\" with {\"skill\": \"<id>\", \"path\": \"references/<file>|assets/<file>\"}.\n");
110        out.push_str("To run skill scripts: use \"skill_script\" with {\"skill\": \"<id>\", \"script\": \"scripts/<file>\", \"args\": [..]}.\n");
111        out.push_str("</skills_usage>");
112
113        if out.len() > self.max_chars {
114            out.truncate(self.max_chars);
115        }
116
117        out.trim_end().to_string()
118    }
119}
120
121#[async_trait]
122impl AgentBehavior for SkillDiscoveryPlugin {
123    fn id(&self) -> &str {
124        SKILLS_DISCOVERY_PLUGIN_ID
125    }
126
127    tirea_contract::declare_plugin_states!(SkillState);
128
129    async fn before_inference(
130        &self,
131        ctx: &ReadOnlyContext<'_>,
132    ) -> ActionSet<BeforeInferenceAction> {
133        let active: HashSet<String> = ctx
134            .snapshot_of::<SkillState>()
135            .ok()
136            .map(|s| s.active.into_iter().collect())
137            .unwrap_or_default();
138
139        let rendered = self.render_catalog(&active, Some(ctx.run_policy()));
140        if rendered.is_empty() {
141            return ActionSet::empty();
142        }
143
144        ActionSet::single(BeforeInferenceAction::AddSystemContext(rendered))
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use crate::{FsSkill, InMemorySkillRegistry, Skill};
152    use serde_json::json;
153    use std::fs;
154    use std::io::Write;
155    use tempfile::TempDir;
156    use tirea_contract::runtime::phase::Phase;
157    use tirea_contract::RunPolicy;
158    use tirea_state::DocCell;
159
160    fn make_registry(skills: Vec<Arc<dyn Skill>>) -> Arc<dyn SkillRegistry> {
161        Arc::new(InMemorySkillRegistry::from_skills(skills))
162    }
163
164    fn make_skills() -> (TempDir, Vec<Arc<dyn Skill>>) {
165        let td = TempDir::new().unwrap();
166        let root = td.path().join("skills");
167        fs::create_dir_all(root.join("a-skill")).unwrap();
168        fs::create_dir_all(root.join("b-skill")).unwrap();
169        let mut fa = fs::File::create(root.join("a-skill").join("SKILL.md")).unwrap();
170        fa.write_all(b"---\nname: a-skill\ndescription: Desc & \"<tag>\"\n---\nBody\n")
171            .unwrap();
172        fs::write(
173            root.join("b-skill").join("SKILL.md"),
174            "---\nname: b-skill\ndescription: ok\n---\nBody\n",
175        )
176        .unwrap();
177
178        let result = FsSkill::discover(root).unwrap();
179        let skills = FsSkill::into_arc_skills(result.skills);
180        (td, skills)
181    }
182
183    fn count_system_context_actions(actions: &ActionSet<BeforeInferenceAction>) -> usize {
184        actions
185            .as_slice()
186            .iter()
187            .filter(|a| matches!(a, BeforeInferenceAction::AddSystemContext(_)))
188            .count()
189    }
190
191    /// Extract system context strings from AddSystemContext actions.
192    fn apply_and_extract_system_contexts(actions: ActionSet<BeforeInferenceAction>) -> Vec<String> {
193        actions
194            .into_iter()
195            .filter_map(|a| match a {
196                BeforeInferenceAction::AddSystemContext(s) => Some(s),
197                _ => None,
198            })
199            .collect()
200    }
201
202    #[tokio::test]
203    async fn injects_catalog_with_usage() {
204        let (_td, skills) = make_skills();
205        let p = SkillDiscoveryPlugin::new(make_registry(skills)).with_limits(10, 8 * 1024);
206        let config = RunPolicy::new();
207        let doc = DocCell::new(json!({}));
208        let ctx = ReadOnlyContext::new(Phase::BeforeInference, "t1", &[], &config, &doc);
209        let actions = AgentBehavior::before_inference(&p, &ctx).await;
210        assert_eq!(count_system_context_actions(&actions), 1);
211        let contexts = apply_and_extract_system_contexts(actions);
212        assert_eq!(contexts.len(), 1);
213        let s = &contexts[0];
214        assert!(s.contains("<available_skills>"));
215        assert!(s.contains("<skills_usage>"));
216        assert!(s.contains("&amp;"));
217        assert!(s.contains("&lt;"));
218        assert!(s.contains("&gt;"));
219    }
220
221    #[tokio::test]
222    async fn marks_active_skills() {
223        let (_td, skills) = make_skills();
224        let p = SkillDiscoveryPlugin::new(make_registry(skills));
225        let config = RunPolicy::new();
226        let doc = DocCell::new(json!({
227            "skills": {
228                "active": ["a"],
229                "instructions": {"a": "Do X"},
230                "references": {},
231                "scripts": {}
232            }
233        }));
234        let ctx = ReadOnlyContext::new(Phase::BeforeInference, "t1", &[], &config, &doc);
235        let actions = AgentBehavior::before_inference(&p, &ctx).await;
236        let contexts = apply_and_extract_system_contexts(actions);
237        let s = &contexts[0];
238        assert!(s.contains("<name>a-skill</name>"));
239    }
240
241    #[tokio::test]
242    async fn does_not_inject_when_skills_empty() {
243        let p = SkillDiscoveryPlugin::new(make_registry(vec![]));
244        let config = RunPolicy::new();
245        let doc = DocCell::new(json!({}));
246        let ctx = ReadOnlyContext::new(Phase::BeforeInference, "t1", &[], &config, &doc);
247        let actions = AgentBehavior::before_inference(&p, &ctx).await;
248        assert!(actions.is_empty());
249    }
250
251    #[tokio::test]
252    async fn does_not_inject_when_all_skills_invalid() {
253        let td = TempDir::new().unwrap();
254        let root = td.path().join("skills");
255        fs::create_dir_all(root.join("BadSkill")).unwrap();
256        fs::write(
257            root.join("BadSkill").join("SKILL.md"),
258            "---\nname: badskill\ndescription: ok\n---\nBody\n",
259        )
260        .unwrap();
261
262        let result = FsSkill::discover(root).unwrap();
263        assert!(result.skills.is_empty());
264
265        let skills = FsSkill::into_arc_skills(result.skills);
266        let p = SkillDiscoveryPlugin::new(make_registry(skills));
267        let config = RunPolicy::new();
268        let doc = DocCell::new(json!({}));
269        let ctx = ReadOnlyContext::new(Phase::BeforeInference, "t1", &[], &config, &doc);
270        let actions = AgentBehavior::before_inference(&p, &ctx).await;
271        assert!(actions.is_empty());
272    }
273
274    #[tokio::test]
275    async fn injects_only_valid_skills_and_never_warnings() {
276        let td = TempDir::new().unwrap();
277        let root = td.path().join("skills");
278        fs::create_dir_all(root.join("good-skill")).unwrap();
279        fs::create_dir_all(root.join("BadSkill")).unwrap();
280        fs::write(
281            root.join("good-skill").join("SKILL.md"),
282            "---\nname: good-skill\ndescription: ok\n---\nBody\n",
283        )
284        .unwrap();
285        fs::write(
286            root.join("BadSkill").join("SKILL.md"),
287            "---\nname: badskill\ndescription: ok\n---\nBody\n",
288        )
289        .unwrap();
290
291        let result = FsSkill::discover(root).unwrap();
292        let skills = FsSkill::into_arc_skills(result.skills);
293        let p = SkillDiscoveryPlugin::new(make_registry(skills));
294        let config = RunPolicy::new();
295        let doc = DocCell::new(json!({}));
296        let ctx = ReadOnlyContext::new(Phase::BeforeInference, "t1", &[], &config, &doc);
297        let actions = AgentBehavior::before_inference(&p, &ctx).await;
298        let contexts = apply_and_extract_system_contexts(actions);
299        assert_eq!(contexts.len(), 1);
300        let s = &contexts[0];
301        assert!(s.contains("<name>good-skill</name>"));
302        assert!(!s.contains("BadSkill"));
303        assert!(!s.contains("skills_warnings"));
304        assert!(!s.contains("Skipped skill"));
305    }
306
307    #[tokio::test]
308    async fn truncates_by_entry_limit_and_emits_note() {
309        let td = TempDir::new().unwrap();
310        let root = td.path().join("skills");
311        for i in 0..5 {
312            let name = format!("s{i}");
313            fs::create_dir_all(root.join(&name)).unwrap();
314            fs::write(
315                root.join(&name).join("SKILL.md"),
316                format!("---\nname: {name}\ndescription: ok\n---\nBody\n"),
317            )
318            .unwrap();
319        }
320        let result = FsSkill::discover(root).unwrap();
321        let skills = FsSkill::into_arc_skills(result.skills);
322        let p = SkillDiscoveryPlugin::new(make_registry(skills)).with_limits(2, 8 * 1024);
323        let config = RunPolicy::new();
324        let doc = DocCell::new(json!({}));
325        let ctx = ReadOnlyContext::new(Phase::BeforeInference, "t1", &[], &config, &doc);
326        let actions = AgentBehavior::before_inference(&p, &ctx).await;
327        let contexts = apply_and_extract_system_contexts(actions);
328        let s = &contexts[0];
329        assert!(s.contains("<available_skills>"));
330        assert!(s.contains("truncated"));
331        assert_eq!(s.matches("<skill>").count(), 2);
332    }
333
334    #[tokio::test]
335    async fn truncates_by_char_limit() {
336        let td = TempDir::new().unwrap();
337        let root = td.path().join("skills");
338        fs::create_dir_all(root.join("s")).unwrap();
339        fs::write(
340            root.join("s").join("SKILL.md"),
341            "---\nname: s\ndescription: A very long description\n---\nBody",
342        )
343        .unwrap();
344        let result = FsSkill::discover(root).unwrap();
345        let skills = FsSkill::into_arc_skills(result.skills);
346        let p = SkillDiscoveryPlugin::new(make_registry(skills)).with_limits(10, 256);
347        let config = RunPolicy::new();
348        let doc = DocCell::new(json!({}));
349        let ctx = ReadOnlyContext::new(Phase::BeforeInference, "t1", &[], &config, &doc);
350        let actions = AgentBehavior::before_inference(&p, &ctx).await;
351        let contexts = apply_and_extract_system_contexts(actions);
352        let s = &contexts[0];
353        assert!(s.len() <= 256);
354    }
355
356    #[tokio::test]
357    async fn filters_catalog_by_runtime_skill_policy() {
358        let (_td, skills) = make_skills();
359        let p = SkillDiscoveryPlugin::new(make_registry(skills));
360        let mut config = RunPolicy::new();
361        config.set_allowed_skills_if_absent(Some(&["a-skill".to_string()]));
362        let doc = DocCell::new(json!({}));
363        let ctx = ReadOnlyContext::new(Phase::BeforeInference, "t1", &[], &config, &doc);
364        let actions = AgentBehavior::before_inference(&p, &ctx).await;
365        let contexts = apply_and_extract_system_contexts(actions);
366        assert_eq!(contexts.len(), 1);
367        let s = &contexts[0];
368        assert!(s.contains("<name>a-skill</name>"));
369        assert!(!s.contains("<name>b-skill</name>"));
370    }
371}