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#[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('&', "&")
45 .replace('<', "<")
46 .replace('>', ">")
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 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("&"));
217 assert!(s.contains("<"));
218 assert!(s.contains(">"));
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}