Skip to main content

bob_adapters/
skills_agent.rs

1//! # Agent Skills
2//!
3//! Agent-skills loader and prompt composer.
4//!
5//! ## Overview
6//!
7//! This module provides skill loading and composition capabilities using the
8//! [`bob-skills`](https://crates.io/crates/bob-skills) crate.
9//!
10//! Skills are predefined prompts and instructions that can be dynamically
11//! selected and injected into the conversation based on the user's input.
12//!
13//! ## Features
14//!
15//! - Load skills from directories
16//! - Recursive directory scanning
17//! - Skill selection based on user input
18//! - Tool filtering (deny/allow lists)
19//! - Token budget management
20//!
21//! ## Example
22//!
23//! ```rust,ignore
24//! use bob_adapters::skills_agent::{
25//!     SkillPromptComposer,
26//!     SkillSourceConfig,
27//!     SkillSelectionPolicy,
28//! };
29//!
30//! // Load skills from a directory
31//! let sources = vec![SkillSourceConfig {
32//!     path: "./skills".into(),
33//!     recursive: true,
34//! }];
35//!
36//! let composer = SkillPromptComposer::from_sources(&sources, 3)?;
37//!
38//! // Render skills for a user input
39//! let policy = SkillSelectionPolicy::default();
40//! let rendered = composer.render_bundle_for_input_with_policy(
41//!     "review this code",
42//!     &policy,
43//! );
44//!
45//! println!("Selected skills: {:?}", rendered.selected_skill_names);
46//! println!("Prompt: {}", rendered.prompt);
47//! ```
48//!
49//! ## Feature Flag
50//!
51//! This module is only available when the `skills-agent` feature is enabled (default).
52
53use std::{
54    fs,
55    path::{Path, PathBuf},
56};
57
58use bob_core::{is_tool_allowed, normalize_tool_list};
59
60/// One skill source entry.
61#[derive(Debug, Clone)]
62pub struct SkillSourceConfig {
63    pub path: PathBuf,
64    pub recursive: bool,
65}
66
67/// Loaded skill data used by prompt composition.
68#[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/// Result of rendering the skills prompt for one user input.
79#[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/// Runtime policy affecting skill selection and rendering.
87#[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/// Errors produced by skill loading/composition.
101#[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
111/// Load all skills from configured sources.
112pub 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/// Stateless prompt composer for loaded skills.
200#[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    // Prefer preserving constraints/checklists; remove explicit example sections first.
439    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}