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