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//! [`agent-skills`](https://crates.io/crates/agent-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 = 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/// Stateless prompt composer for loaded skills.
201#[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    // Prefer preserving constraints/checklists; remove explicit example sections first.
440    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}