1use std::{
54 fs,
55 path::{Path, PathBuf},
56};
57
58use bob_core::{is_tool_allowed, normalize_tool_list};
59
60#[derive(Debug, Clone)]
62pub struct SkillSourceConfig {
63 pub path: PathBuf,
64 pub recursive: bool,
65}
66
67#[derive(Debug, Clone)]
69pub struct LoadedSkill {
70 pub name: String,
71 pub description: String,
72 pub body: String,
73 pub tags: Vec<String>,
74 pub allowed_tools: Vec<String>,
75 pub source_dir: PathBuf,
76}
77
78#[derive(Debug, Clone, Default)]
80pub struct RenderedSkillsPrompt {
81 pub prompt: String,
82 pub selected_skill_names: Vec<String>,
83 pub selected_allowed_tools: Vec<String>,
84}
85
86#[derive(Debug, Clone)]
88pub struct SkillSelectionPolicy {
89 pub deny_tools: Vec<String>,
90 pub allow_tools: Option<Vec<String>>,
91 pub token_budget_tokens: usize,
92}
93
94impl Default for SkillSelectionPolicy {
95 fn default() -> Self {
96 Self { deny_tools: Vec::new(), allow_tools: None, token_budget_tokens: 1_800 }
97 }
98}
99
100#[derive(Debug, thiserror::Error)]
102pub enum SkillsAgentError {
103 #[error("skill source path does not exist: {path}")]
104 SourceNotFound { path: String },
105 #[error("failed to list directory '{path}': {message}")]
106 ReadDir { path: String, message: String },
107 #[error("failed to load skill directory '{path}': {message}")]
108 LoadSkill { path: String, message: String },
109}
110
111pub fn load_skills_from_sources(
113 sources: &[SkillSourceConfig],
114) -> Result<Vec<LoadedSkill>, SkillsAgentError> {
115 let mut dirs = Vec::new();
116 for source in sources {
117 collect_skill_dirs(&source.path, source.recursive, &mut dirs)?;
118 }
119
120 dirs.sort();
121 dirs.dedup();
122
123 let mut loaded = Vec::with_capacity(dirs.len());
124 for dir in dirs {
125 let skill_dir = agent_skills::SkillDirectory::load(&dir).map_err(|err| {
126 SkillsAgentError::LoadSkill {
127 path: dir.display().to_string(),
128 message: err.to_string(),
129 }
130 })?;
131
132 let skill = skill_dir.skill();
133 let tags = skill
134 .frontmatter()
135 .metadata()
136 .and_then(|meta| meta.get("tags"))
137 .map(parse_tags)
138 .unwrap_or_default();
139 let allowed_tools = skill
140 .frontmatter()
141 .allowed_tools()
142 .map(|tools| tools.iter().map(ToString::to_string).collect())
143 .unwrap_or_default();
144 loaded.push(LoadedSkill {
145 name: skill.name().as_str().to_string(),
146 description: skill.description().as_str().to_string(),
147 body: skill.body_trimmed().to_string(),
148 tags,
149 allowed_tools,
150 source_dir: dir,
151 });
152 }
153
154 loaded.sort_by(|a, b| a.name.cmp(&b.name));
155 Ok(loaded)
156}
157
158fn collect_skill_dirs(
159 path: &Path,
160 recursive: bool,
161 out: &mut Vec<PathBuf>,
162) -> Result<(), SkillsAgentError> {
163 if !path.exists() {
164 return Err(SkillsAgentError::SourceNotFound { path: path.display().to_string() });
165 }
166
167 if path.join("SKILL.md").is_file() {
168 out.push(path.to_path_buf());
169 return Ok(());
170 }
171
172 let read_dir = fs::read_dir(path).map_err(|err| SkillsAgentError::ReadDir {
173 path: path.display().to_string(),
174 message: err.to_string(),
175 })?;
176
177 for entry in read_dir {
178 let entry = entry.map_err(|err| SkillsAgentError::ReadDir {
179 path: path.display().to_string(),
180 message: err.to_string(),
181 })?;
182 let candidate = entry.path();
183 if !candidate.is_dir() {
184 continue;
185 }
186
187 if candidate.join("SKILL.md").is_file() {
188 out.push(candidate);
189 continue;
190 }
191
192 if recursive {
193 collect_skill_dirs(&candidate, true, out)?;
194 }
195 }
196
197 Ok(())
198}
199
200#[derive(Debug, Clone)]
202pub struct SkillPromptComposer {
203 skills: Vec<LoadedSkill>,
204 max_selected: usize,
205}
206
207impl SkillPromptComposer {
208 #[must_use]
209 pub fn new(skills: Vec<LoadedSkill>, max_selected: usize) -> Self {
210 Self { skills, max_selected: max_selected.max(1) }
211 }
212
213 pub fn from_sources(
214 sources: &[SkillSourceConfig],
215 max_selected: usize,
216 ) -> Result<Self, SkillsAgentError> {
217 let skills = load_skills_from_sources(sources)?;
218 Ok(Self::new(skills, max_selected))
219 }
220
221 #[must_use]
222 pub fn skills(&self) -> &[LoadedSkill] {
223 &self.skills
224 }
225
226 #[must_use]
227 pub fn select_for_input<'a>(&'a self, input: &str) -> Vec<&'a LoadedSkill> {
228 self.select_for_input_with_policy(input, &SkillSelectionPolicy::default())
229 }
230
231 #[must_use]
232 pub fn select_for_input_with_policy<'a>(
233 &'a self,
234 input: &str,
235 policy: &SkillSelectionPolicy,
236 ) -> Vec<&'a LoadedSkill> {
237 let input_lower = input.to_ascii_lowercase();
238 if input_lower.trim().is_empty() {
239 return Vec::new();
240 }
241 let input_tokens = tokenize(&input_lower);
242
243 let mut scored: Vec<(f64, &LoadedSkill)> = self
244 .skills
245 .iter()
246 .filter(|skill| is_skill_compatible_with_policy(skill, policy))
247 .map(|skill| {
248 let score = score_skill(skill, &input_lower, &input_tokens);
249 (score, skill)
250 })
251 .filter(|(score, _)| *score > 0.0)
252 .collect();
253
254 scored.sort_by(|(a_score, a), (b_score, b)| {
255 b_score.total_cmp(a_score).then_with(|| a.name.cmp(&b.name))
256 });
257
258 scored.into_iter().take(self.max_selected).map(|(_, skill)| skill).collect()
259 }
260
261 #[must_use]
262 pub fn render_for_input(&self, input: &str) -> String {
263 self.render_for_input_with_policy(input, &SkillSelectionPolicy::default())
264 }
265
266 #[must_use]
267 pub fn render_for_input_with_policy(
268 &self,
269 input: &str,
270 policy: &SkillSelectionPolicy,
271 ) -> String {
272 self.render_bundle_for_input_with_policy(input, policy).prompt
273 }
274
275 #[must_use]
276 pub fn render_bundle_for_input_with_policy(
277 &self,
278 input: &str,
279 policy: &SkillSelectionPolicy,
280 ) -> RenderedSkillsPrompt {
281 let selected = self.select_for_input_with_policy(input, policy);
282 if selected.is_empty() {
283 return RenderedSkillsPrompt::default();
284 }
285
286 let mut entries: Vec<SkillRenderEntry<'_>> =
287 selected.iter().map(|skill| SkillRenderEntry::from_skill(skill, policy)).collect();
288
289 let mut prompt = render_prompt_from_entries(&entries);
290 if estimate_text_tokens(&prompt) > policy.token_budget_tokens {
291 for entry in &mut entries {
292 entry.body = strip_examples_from_body(&entry.body);
293 }
294 prompt = render_prompt_from_entries(&entries);
295 }
296
297 while entries.len() > 1 && estimate_text_tokens(&prompt) > policy.token_budget_tokens {
298 entries.pop();
299 prompt = render_prompt_from_entries(&entries);
300 }
301
302 if entries.is_empty() {
303 return RenderedSkillsPrompt::default();
304 }
305
306 let mut selected_allowed_tools = entries
307 .iter()
308 .flat_map(|entry| entry.allowed_tools.iter().cloned())
309 .collect::<Vec<_>>();
310 selected_allowed_tools.sort();
311 selected_allowed_tools.dedup();
312
313 RenderedSkillsPrompt {
314 prompt,
315 selected_skill_names: entries.iter().map(|entry| entry.skill.name.clone()).collect(),
316 selected_allowed_tools,
317 }
318 }
319}
320
321#[derive(Debug, Clone)]
322struct SkillRenderEntry<'a> {
323 skill: &'a LoadedSkill,
324 body: String,
325 allowed_tools: Vec<String>,
326}
327
328impl<'a> SkillRenderEntry<'a> {
329 fn from_skill(skill: &'a LoadedSkill, policy: &SkillSelectionPolicy) -> Self {
330 Self {
331 skill,
332 body: skill.body.clone(),
333 allowed_tools: effective_allowed_tools(skill, policy),
334 }
335 }
336}
337
338fn render_prompt_from_entries(entries: &[SkillRenderEntry<'_>]) -> String {
339 let mut out = String::from("Use these skills when relevant:");
340 out.push_str("\n\n| Skill | Description | Tags | Allowed Tools |");
341 out.push_str("\n| --- | --- | --- | --- |");
342 for entry in entries {
343 let skill = entry.skill;
344 let tags = if skill.tags.is_empty() { "-".to_string() } else { skill.tags.join(", ") };
345 let allowed_tools = if entry.allowed_tools.is_empty() {
346 "-".to_string()
347 } else {
348 entry.allowed_tools.join(", ")
349 };
350 out.push_str(&format!(
351 "\n| `{}` | {} | {} | {} |",
352 escape_table_cell(&skill.name),
353 escape_table_cell(&skill.description),
354 escape_table_cell(&tags),
355 escape_table_cell(&allowed_tools),
356 ));
357 }
358
359 for entry in entries {
360 let skill = entry.skill;
361 out.push_str(&format!(
362 "\n\n### Skill `{}`\nDescription: {}\n{}",
363 skill.name, skill.description, entry.body
364 ));
365 }
366 out
367}
368
369fn score_skill(skill: &LoadedSkill, input_lower: &str, input_tokens: &[String]) -> f64 {
370 let mut score = 0.0_f64;
371 let name_lower = skill.name.to_ascii_lowercase();
372
373 if input_lower.contains(&name_lower) {
374 score += 1.0;
375 }
376
377 let tag_overlap = skill
378 .tags
379 .iter()
380 .map(|tag| tag.to_ascii_lowercase())
381 .filter(|tag| input_tokens.iter().any(|token| token == tag))
382 .count();
383 if tag_overlap > 0 {
384 score += 0.4 * tag_overlap as f64;
385 }
386
387 let haystack = format!(
388 "{} {} {}",
389 skill.name.to_ascii_lowercase(),
390 skill.description.to_ascii_lowercase(),
391 skill.body.to_ascii_lowercase()
392 );
393
394 let mut keyword_hits = 0_u32;
395 for token in input_tokens {
396 if token.len() >= 3 && haystack.contains(token) {
397 keyword_hits += 1;
398 }
399 }
400 score += 0.2 * f64::from(keyword_hits.min(10));
401
402 score
403}
404
405fn is_skill_compatible_with_policy(skill: &LoadedSkill, policy: &SkillSelectionPolicy) -> bool {
406 if skill.allowed_tools.is_empty() {
407 return true;
408 }
409
410 !effective_allowed_tools(skill, policy).is_empty()
411}
412
413fn effective_allowed_tools(skill: &LoadedSkill, policy: &SkillSelectionPolicy) -> Vec<String> {
414 if skill.allowed_tools.is_empty() {
415 return Vec::new();
416 }
417
418 normalize_tool_list(
419 skill
420 .allowed_tools
421 .iter()
422 .filter(|tool| is_tool_allowed(tool, &policy.deny_tools, policy.allow_tools.as_deref()))
423 .map(String::as_str),
424 )
425}
426
427fn parse_tags(raw: &str) -> Vec<String> {
428 raw.split(|ch: char| ch == ',' || ch.is_whitespace())
429 .filter(|part| !part.is_empty())
430 .map(|part| part.to_ascii_lowercase())
431 .collect()
432}
433
434fn estimate_text_tokens(text: &str) -> usize {
435 text.split_whitespace().count().max(1)
436}
437
438fn strip_examples_from_body(body: &str) -> String {
439 let mut out = Vec::new();
441 let mut in_example_section = false;
442
443 for line in body.lines() {
444 let trimmed = line.trim();
445
446 if trimmed.starts_with('#') {
447 let heading = trimmed.trim_start_matches('#').trim().to_ascii_lowercase();
448 in_example_section = heading.contains("example");
449 if !in_example_section {
450 out.push(line);
451 }
452 continue;
453 }
454
455 if in_example_section {
456 continue;
457 }
458
459 if trimmed.to_ascii_lowercase().starts_with("example:") {
460 in_example_section = true;
461 continue;
462 }
463
464 out.push(line);
465 }
466
467 out.join("\n").trim().to_string()
468}
469
470fn escape_table_cell(value: &str) -> String {
471 value.replace('|', "\\|")
472}
473
474fn tokenize(input: &str) -> Vec<String> {
475 input
476 .split(|ch: char| !ch.is_ascii_alphanumeric() && ch != '-' && ch != '_')
477 .filter(|part| !part.is_empty())
478 .map(ToString::to_string)
479 .collect()
480}
481
482#[cfg(test)]
483mod tests {
484 use std::fs;
485
486 use tempfile::TempDir;
487
488 use super::*;
489
490 fn write_skill(
491 root: &Path,
492 name: &str,
493 description: &str,
494 body: &str,
495 ) -> Result<PathBuf, Box<dyn std::error::Error>> {
496 let dir = root.join(name);
497 fs::create_dir_all(&dir)?;
498 fs::write(
499 dir.join("SKILL.md"),
500 format!("---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n\n{body}\n"),
501 )?;
502 Ok(dir)
503 }
504
505 #[test]
506 fn loads_skills_from_directory_source() -> Result<(), Box<dyn std::error::Error>> {
507 let temp = TempDir::new()?;
508 let skills_root = temp.path().join("skills");
509 fs::create_dir_all(&skills_root)?;
510
511 write_skill(
512 &skills_root,
513 "rust-review",
514 "Review Rust code for correctness.",
515 "Focus on bug risk.",
516 )?;
517 write_skill(
518 &skills_root,
519 "sql-tuning",
520 "Optimize SQL queries.",
521 "Look for missing indexes.",
522 )?;
523
524 let loaded =
525 load_skills_from_sources(&[SkillSourceConfig { path: skills_root, recursive: false }])?;
526
527 assert_eq!(loaded.len(), 2);
528 assert_eq!(loaded[0].name, "rust-review");
529 assert_eq!(loaded[1].name, "sql-tuning");
530 Ok(())
531 }
532
533 #[test]
534 fn selects_skill_by_name_mention() {
535 let skills = vec![
536 LoadedSkill {
537 name: "rust-review".to_string(),
538 description: "Review Rust code for bugs.".to_string(),
539 body: "Check panics and edge cases.".to_string(),
540 tags: Vec::new(),
541 allowed_tools: Vec::new(),
542 source_dir: PathBuf::from("/tmp/rust-review"),
543 },
544 LoadedSkill {
545 name: "sql-tuning".to_string(),
546 description: "Tune SQL query plans.".to_string(),
547 body: "Inspect indexes.".to_string(),
548 tags: Vec::new(),
549 allowed_tools: Vec::new(),
550 source_dir: PathBuf::from("/tmp/sql-tuning"),
551 },
552 ];
553 let composer = SkillPromptComposer::new(skills, 1);
554
555 let selected = composer.select_for_input("please do rust-review on this module");
556 assert_eq!(selected.len(), 1);
557 assert_eq!(selected[0].name, "rust-review");
558 }
559
560 #[test]
561 fn renders_prompt_with_selected_skill_content() {
562 let skills = vec![LoadedSkill {
563 name: "sql-tuning".to_string(),
564 description: "Tune SQL query plans.".to_string(),
565 body: "Look at EXPLAIN and indexes.".to_string(),
566 tags: Vec::new(),
567 allowed_tools: Vec::new(),
568 source_dir: PathBuf::from("/tmp/sql-tuning"),
569 }];
570 let composer = SkillPromptComposer::new(skills, 1);
571
572 let prompt = composer.render_for_input("need help to tuning sql index");
573 assert!(prompt.contains("Skill `sql-tuning`"));
574 assert!(prompt.contains("Look at EXPLAIN"));
575 }
576
577 #[test]
578 fn selects_skill_by_metadata_tags() -> Result<(), Box<dyn std::error::Error>> {
579 let temp = TempDir::new()?;
580 let skills_root = temp.path().join("skills");
581 fs::create_dir_all(&skills_root)?;
582
583 let dir = skills_root.join("db-advisor");
584 fs::create_dir_all(&dir)?;
585 fs::write(
586 dir.join("SKILL.md"),
587 "---\nname: db-advisor\ndescription: Generic advisor.\nmetadata:\n tags: postgres migration\n---\n\n# db-advisor\n\nFollow checklist carefully.\n",
588 )?;
589
590 let composer = SkillPromptComposer::from_sources(
591 &[SkillSourceConfig { path: skills_root, recursive: false }],
592 3,
593 )?;
594
595 let selected = composer.select_for_input("need postgres migration plan");
596 assert_eq!(selected.len(), 1);
597 assert_eq!(selected[0].name, "db-advisor");
598 Ok(())
599 }
600
601 #[test]
602 fn render_includes_summary_table() {
603 let skills = vec![LoadedSkill {
604 name: "rust-review".to_string(),
605 description: "Review Rust code".to_string(),
606 body: "Focus on panic-safety and edge cases.".to_string(),
607 tags: Vec::new(),
608 allowed_tools: Vec::new(),
609 source_dir: PathBuf::from("/tmp/rust-review"),
610 }];
611 let composer = SkillPromptComposer::new(skills, 1);
612
613 let prompt = composer.render_for_input("review this rust module");
614 assert!(prompt.contains("| Skill |"), "prompt should include summary table header");
615 assert!(prompt.contains("rust-review"), "prompt should include selected skill row");
616 }
617
618 #[test]
619 fn policy_filters_skill_when_all_allowed_tools_denied() {
620 let skills = vec![LoadedSkill {
621 name: "danger-shell".to_string(),
622 description: "Runs shell commands.".to_string(),
623 body: "Only use when explicitly required.".to_string(),
624 tags: vec!["shell".to_string()],
625 allowed_tools: vec!["local/shell_exec".to_string()],
626 source_dir: PathBuf::from("/tmp/danger-shell"),
627 }];
628 let composer = SkillPromptComposer::new(skills, 3);
629 let policy = SkillSelectionPolicy {
630 deny_tools: vec!["local/shell_exec".to_string()],
631 allow_tools: None,
632 token_budget_tokens: 1_800,
633 };
634
635 let selected = composer.select_for_input_with_policy("need shell access", &policy);
636 assert!(selected.is_empty(), "skill should be filtered by deny_tools policy");
637 }
638
639 #[test]
640 fn policy_filters_skill_when_allowlist_disjoint() {
641 let skills = vec![LoadedSkill {
642 name: "fs-read".to_string(),
643 description: "Read repository files.".to_string(),
644 body: "Use read-only tool.".to_string(),
645 tags: vec!["repo".to_string()],
646 allowed_tools: vec!["local/read_file".to_string()],
647 source_dir: PathBuf::from("/tmp/fs-read"),
648 }];
649 let composer = SkillPromptComposer::new(skills, 3);
650 let policy = SkillSelectionPolicy {
651 deny_tools: Vec::new(),
652 allow_tools: Some(vec!["local/write_file".to_string()]),
653 token_budget_tokens: 1_800,
654 };
655
656 let selected = composer.select_for_input_with_policy("inspect repository files", &policy);
657 assert!(selected.is_empty(), "skill should be filtered by runtime allowlist");
658 }
659
660 #[test]
661 fn token_budget_drops_low_ranked_skills() {
662 let skills = vec![
663 LoadedSkill {
664 name: "rust-review".to_string(),
665 description: "Review Rust code".to_string(),
666 body: "panic safety".to_string(),
667 tags: vec!["rust".to_string()],
668 allowed_tools: Vec::new(),
669 source_dir: PathBuf::from("/tmp/rust-review"),
670 },
671 LoadedSkill {
672 name: "sql-tuning".to_string(),
673 description: "Tune SQL".to_string(),
674 body: "index recommendations and query rewrite guidance".to_string(),
675 tags: vec!["sql".to_string()],
676 allowed_tools: Vec::new(),
677 source_dir: PathBuf::from("/tmp/sql-tuning"),
678 },
679 ];
680 let composer = SkillPromptComposer::new(skills, 3);
681 let policy = SkillSelectionPolicy {
682 deny_tools: Vec::new(),
683 allow_tools: None,
684 token_budget_tokens: 5,
685 };
686
687 let prompt = composer.render_for_input_with_policy("rust sql", &policy);
688 assert!(
689 prompt.contains("rust-review") || prompt.contains("sql-tuning"),
690 "at least one higher-priority skill should remain under tight budget"
691 );
692 }
693
694 #[test]
695 fn token_budget_truncates_examples_before_drop() {
696 let skills = vec![LoadedSkill {
697 name: "rust-review".to_string(),
698 description: "Review Rust code".to_string(),
699 body: "Checklist:\n- Focus on safety\n\n## Example\n```rust\nlet a = very_long_example_code_path();\nprintln!(\"{a}\");\n```"
700 .to_string(),
701 tags: vec!["rust".to_string()],
702 allowed_tools: Vec::new(),
703 source_dir: PathBuf::from("/tmp/rust-review"),
704 }];
705 let composer = SkillPromptComposer::new(skills, 1);
706 let policy = SkillSelectionPolicy {
707 deny_tools: Vec::new(),
708 allow_tools: None,
709 token_budget_tokens: 12,
710 };
711
712 let prompt = composer.render_for_input_with_policy("need rust review", &policy);
713 assert!(prompt.contains("Checklist"), "core checklist should be preserved");
714 assert!(
715 !prompt.contains("very_long_example_code_path"),
716 "example blocks should be removed first under budget pressure"
717 );
718 }
719
720 #[test]
721 fn token_budget_keeps_non_example_code_blocks() {
722 let skills = vec![LoadedSkill {
723 name: "repo-review".to_string(),
724 description: "Review repository state".to_string(),
725 body: "## Procedure\n```bash\nrg --files\n```\n\n## Example\n```bash\necho demo this command is only an example\n```"
726 .to_string(),
727 tags: vec!["review".to_string()],
728 allowed_tools: Vec::new(),
729 source_dir: PathBuf::from("/tmp/repo-review"),
730 }];
731 let composer = SkillPromptComposer::new(skills, 1);
732 let policy = SkillSelectionPolicy {
733 deny_tools: Vec::new(),
734 allow_tools: Some(vec!["local/read_file".to_string(), "local/list_dir".to_string()]),
735 token_budget_tokens: 14,
736 };
737
738 let prompt = composer.render_for_input_with_policy("review repo", &policy);
739 assert!(
740 prompt.contains("rg --files"),
741 "non-example procedural code blocks should stay intact"
742 );
743 assert!(
744 !prompt.contains("echo demo this command is only an example"),
745 "example blocks should still be removed under budget pressure"
746 );
747 }
748
749 #[test]
750 fn render_bundle_returns_effective_allowed_tools() {
751 let skills = vec![LoadedSkill {
752 name: "repo-read".to_string(),
753 description: "Read repo files".to_string(),
754 body: "Use read file and list dir.".to_string(),
755 tags: vec!["repo".to_string()],
756 allowed_tools: vec!["local/read_file".to_string(), "local/list_dir".to_string()],
757 source_dir: PathBuf::from("/tmp/repo-read"),
758 }];
759 let composer = SkillPromptComposer::new(skills, 1);
760 let policy = SkillSelectionPolicy {
761 deny_tools: vec!["local/list_dir".to_string()],
762 allow_tools: Some(vec!["local/read_file".to_string(), "local/write_file".to_string()]),
763 token_budget_tokens: 1_800,
764 };
765
766 let rendered = composer.render_bundle_for_input_with_policy("inspect repo", &policy);
767 assert_eq!(rendered.selected_skill_names, vec!["repo-read".to_string()]);
768 assert_eq!(rendered.selected_allowed_tools, vec!["local/read_file".to_string()]);
769 }
770}