Skip to main content

bamboo_engine/skills/
mod.rs

1//! Agent skill management (re-exported from bamboo-agent-skill crate).
2
3pub mod access_control;
4pub mod context;
5pub mod resource_helpers;
6pub mod runtime_metadata;
7pub mod selection;
8pub mod session_port;
9pub mod store;
10pub mod types;
11
12pub use store::{SkillStore, SkillUpdate};
13pub use types::*;
14
15use std::collections::{BTreeSet, HashSet};
16use std::sync::Arc;
17
18const MAX_UNSELECTED_SKILLS_IN_CONTEXT: usize = 24;
19
20fn tokenize_request_hint(request_hint: &str) -> Vec<String> {
21    let mut seen = HashSet::new();
22    let mut tokens = Vec::new();
23
24    for token in request_hint
25        .split(|character: char| !character.is_ascii_alphanumeric() && character != '-')
26        .map(|token| token.trim().to_lowercase())
27        .filter(|token| token.len() >= 3)
28    {
29        if seen.insert(token.clone()) {
30            tokens.push(token);
31        }
32    }
33
34    tokens
35}
36
37fn skill_match_score(skill: &SkillDefinition, tokens: &[String]) -> usize {
38    if tokens.is_empty() {
39        return 0;
40    }
41
42    let searchable = format!(
43        "{} {} {} {}",
44        skill.id.to_lowercase(),
45        skill.name.to_lowercase(),
46        skill.description.to_lowercase(),
47        skill
48            .tool_refs
49            .iter()
50            .map(|tool| tool.to_lowercase())
51            .collect::<Vec<_>>()
52            .join(" ")
53    );
54
55    tokens
56        .iter()
57        .map(|token| {
58            if searchable.contains(token) {
59                if skill.id.to_lowercase().contains(token)
60                    || skill.name.to_lowercase().contains(token)
61                {
62                    3
63                } else {
64                    1
65                }
66            } else {
67                0
68            }
69        })
70        .sum()
71}
72
73fn shortlist_skills_for_context(
74    mut skills: Vec<SkillDefinition>,
75    request_hint: Option<&str>,
76) -> Vec<SkillDefinition> {
77    if skills.len() <= MAX_UNSELECTED_SKILLS_IN_CONTEXT {
78        return skills;
79    }
80
81    let hint_tokens = request_hint
82        .map(str::trim)
83        .filter(|value| !value.is_empty())
84        .map(tokenize_request_hint)
85        .unwrap_or_default();
86
87    if hint_tokens.is_empty() {
88        skills.sort_by_key(|s| s.id.clone());
89        skills.truncate(MAX_UNSELECTED_SKILLS_IN_CONTEXT);
90        return skills;
91    }
92
93    let mut ranked: Vec<(usize, SkillDefinition)> = skills
94        .into_iter()
95        .map(|skill| (skill_match_score(&skill, &hint_tokens), skill))
96        .collect();
97
98    ranked.sort_by(|(left_score, left_skill), (right_score, right_skill)| {
99        right_score
100            .cmp(left_score)
101            .then_with(|| left_skill.id.cmp(&right_skill.id))
102    });
103
104    let mut selected = Vec::new();
105    let mut selected_ids = HashSet::new();
106
107    for (score, skill) in ranked.iter().cloned() {
108        if score == 0 || selected.len() >= MAX_UNSELECTED_SKILLS_IN_CONTEXT {
109            break;
110        }
111        selected_ids.insert(skill.id.clone());
112        selected.push(skill);
113    }
114
115    if selected.len() < MAX_UNSELECTED_SKILLS_IN_CONTEXT {
116        let mut fallback: Vec<SkillDefinition> = ranked
117            .into_iter()
118            .map(|(_, skill)| skill)
119            .filter(|skill| !selected_ids.contains(&skill.id))
120            .collect();
121        fallback.sort_by_key(|s| s.id.clone());
122        let remaining = MAX_UNSELECTED_SKILLS_IN_CONTEXT - selected.len();
123        selected.extend(fallback.into_iter().take(remaining));
124    }
125
126    selected.sort_by_key(|s| s.id.clone());
127    selected
128}
129
130fn filter_disabled_skills(
131    skills: Vec<SkillDefinition>,
132    disabled_skill_ids: &BTreeSet<String>,
133) -> Vec<SkillDefinition> {
134    if disabled_skill_ids.is_empty() {
135        return skills;
136    }
137
138    skills
139        .into_iter()
140        .filter(|skill| !disabled_skill_ids.contains(&skill.id))
141        .collect()
142}
143
144/// Skill manager instance (convenience wrapper around SkillStore).
145#[derive(Clone)]
146pub struct SkillManager {
147    store: Arc<SkillStore>,
148}
149
150impl SkillManager {
151    /// Create a new skill manager with default configuration.
152    pub fn new() -> Self {
153        Self {
154            store: Arc::new(SkillStore::default()),
155        }
156    }
157
158    /// Create a new skill manager with custom configuration.
159    pub fn with_config(config: SkillStoreConfig) -> Self {
160        Self {
161            store: Arc::new(SkillStore::new(config)),
162        }
163    }
164
165    /// Initialize the manager.
166    pub async fn initialize(&self) -> SkillResult<()> {
167        self.store.initialize().await
168    }
169
170    /// Get the underlying store.
171    pub fn store(&self) -> &SkillStore {
172        &self.store
173    }
174
175    pub(crate) async fn list_skills_for_selection(
176        &self,
177        disabled_skill_ids: &BTreeSet<String>,
178        selected_skill_ids: Option<&[String]>,
179        selected_skill_mode: Option<&str>,
180    ) -> Vec<SkillDefinition> {
181        let skills = if selected_skill_mode.is_some() {
182            self.store
183                .list_skills_for_mode(None, selected_skill_mode)
184                .await
185        } else {
186            self.store.list_skills(None, true).await
187        };
188        let skills = filter_disabled_skills(skills, disabled_skill_ids);
189        let Some(selected_skill_ids) = selected_skill_ids else {
190            return skills;
191        };
192
193        let selected_set: HashSet<&str> = selected_skill_ids
194            .iter()
195            .map(|id| id.trim())
196            .filter(|id| !id.is_empty())
197            .collect();
198        if selected_set.is_empty() {
199            return skills;
200        }
201
202        let filtered: Vec<SkillDefinition> = skills
203            .into_iter()
204            .filter(|skill| selected_set.contains(skill.id.as_str()))
205            .collect();
206
207        if filtered.len() != selected_set.len() {
208            let missing: Vec<&str> = selected_set
209                .iter()
210                .copied()
211                .filter(|selected| !filtered.iter().any(|skill| skill.id == *selected))
212                .collect();
213            if !missing.is_empty() {
214                tracing::warn!(
215                    "Some selected skills were not found on disk and will be ignored: {:?}",
216                    missing
217                );
218            }
219        }
220
221        filtered
222    }
223
224    /// Build system prompt context from a selected subset of skills.
225    pub async fn build_skill_context_for_selection(
226        &self,
227        disabled_skill_ids: &BTreeSet<String>,
228        selected_skill_ids: Option<&[String]>,
229    ) -> String {
230        self.build_skill_context_for_request_with_mode(
231            disabled_skill_ids,
232            selected_skill_ids,
233            None,
234            None,
235        )
236        .await
237    }
238
239    /// Build system prompt context from a selected subset of skills with mode override.
240    pub async fn build_skill_context_for_selection_with_mode(
241        &self,
242        disabled_skill_ids: &BTreeSet<String>,
243        selected_skill_ids: Option<&[String]>,
244        selected_skill_mode: Option<&str>,
245    ) -> String {
246        self.build_skill_context_for_request_with_mode(
247            disabled_skill_ids,
248            selected_skill_ids,
249            selected_skill_mode,
250            None,
251        )
252        .await
253    }
254
255    pub async fn resolve_skills_for_request_with_mode(
256        &self,
257        disabled_skill_ids: &BTreeSet<String>,
258        selected_skill_ids: Option<&[String]>,
259        selected_skill_mode: Option<&str>,
260        request_hint: Option<&str>,
261    ) -> Vec<SkillDefinition> {
262        let mut skills = self
263            .list_skills_for_selection(disabled_skill_ids, selected_skill_ids, selected_skill_mode)
264            .await;
265
266        if selected_skill_ids.is_none() {
267            let original_len = skills.len();
268            skills = shortlist_skills_for_context(skills, request_hint);
269            if skills.len() < original_len {
270                tracing::info!(
271                    "Skill context shortlisted from {} to {} entries (request_hint_present={})",
272                    original_len,
273                    skills.len(),
274                    request_hint
275                        .map(str::trim)
276                        .is_some_and(|value| !value.is_empty())
277                );
278            }
279        }
280
281        skills
282    }
283
284    /// Build system prompt context from a selected subset of skills with mode and user request hint.
285    pub async fn build_skill_context_for_request_with_mode(
286        &self,
287        disabled_skill_ids: &BTreeSet<String>,
288        selected_skill_ids: Option<&[String]>,
289        selected_skill_mode: Option<&str>,
290        request_hint: Option<&str>,
291    ) -> String {
292        let skills = self
293            .resolve_skills_for_request_with_mode(
294                disabled_skill_ids,
295                selected_skill_ids,
296                selected_skill_mode,
297                request_hint,
298            )
299            .await;
300
301        tracing::info!(
302            "Building skill context with {} skill(s), selection_mode={}, skill_mode={}",
303            skills.len(),
304            if selected_skill_ids.is_some() {
305                "selected"
306            } else {
307                "all"
308            },
309            selected_skill_mode.unwrap_or("default"),
310        );
311        context::build_skill_context(&skills)
312    }
313
314    /// Build system prompt context from all skills.
315    pub async fn build_skill_context(
316        &self,
317        disabled_skill_ids: &BTreeSet<String>,
318        _chat_id: Option<&str>,
319    ) -> String {
320        self.build_skill_context_for_selection(disabled_skill_ids, None)
321            .await
322    }
323
324    /// Get allowed tool refs from a selected subset of skills.
325    pub async fn get_allowed_tools_for_selection(
326        &self,
327        disabled_skill_ids: &BTreeSet<String>,
328        selected_skill_ids: Option<&[String]>,
329    ) -> Vec<String> {
330        self.get_allowed_tools_for_selection_with_mode(disabled_skill_ids, selected_skill_ids, None)
331            .await
332    }
333
334    /// Get allowed tool refs from a selected subset of skills with mode override.
335    pub async fn get_allowed_tools_for_selection_with_mode(
336        &self,
337        disabled_skill_ids: &BTreeSet<String>,
338        selected_skill_ids: Option<&[String]>,
339        selected_skill_mode: Option<&str>,
340    ) -> Vec<String> {
341        let skills = self
342            .list_skills_for_selection(disabled_skill_ids, selected_skill_ids, selected_skill_mode)
343            .await;
344
345        let mut tools: Vec<String> = skills
346            .into_iter()
347            .flat_map(|skill| skill.tool_refs)
348            .collect::<HashSet<_>>()
349            .into_iter()
350            .collect();
351
352        tools.sort();
353        tools
354    }
355
356    /// Get allowed tool refs from all skills.
357    pub async fn get_allowed_tools(
358        &self,
359        disabled_skill_ids: &BTreeSet<String>,
360        _chat_id: Option<&str>,
361    ) -> Vec<String> {
362        self.get_allowed_tools_for_selection(disabled_skill_ids, None)
363            .await
364    }
365}
366
367impl Default for SkillManager {
368    fn default() -> Self {
369        Self::new()
370    }
371}
372
373#[cfg(test)]
374mod tests {
375    use std::collections::BTreeSet;
376
377    use super::{
378        filter_disabled_skills, shortlist_skills_for_context, tokenize_request_hint,
379        SkillDefinition,
380    };
381
382    fn demo_skill(id: &str, description: &str) -> SkillDefinition {
383        SkillDefinition::new(id, id, description, "prompt")
384    }
385
386    #[test]
387    fn tokenize_request_hint_dedupes_and_filters_short_tokens() {
388        let tokens = tokenize_request_hint("fix ui ui in app and api");
389        assert!(tokens.contains(&"fix".to_string()));
390        assert!(tokens.contains(&"app".to_string()));
391        assert!(tokens.contains(&"api".to_string()));
392        assert_eq!(
393            tokens.iter().filter(|token| token.as_str() == "ui").count(),
394            0
395        );
396    }
397
398    #[test]
399    fn shortlist_skills_for_context_prefers_request_matches() {
400        let mut skills = Vec::new();
401        for index in 0..30 {
402            skills.push(demo_skill(
403                &format!("skill-{index:02}"),
404                "generic helper skill",
405            ));
406        }
407        skills.push(demo_skill("react-optimizer", "react vite optimization"));
408
409        let shortlisted = shortlist_skills_for_context(skills, Some("optimize react vite build"));
410        assert!(shortlisted.len() <= 24);
411        assert!(shortlisted
412            .iter()
413            .any(|skill| skill.id == "react-optimizer"));
414    }
415
416    #[test]
417    fn filter_disabled_skills_removes_matching_skill_ids() {
418        let skills = vec![
419            demo_skill("pdf", "pdf helper"),
420            demo_skill("pptx", "ppt helper"),
421        ];
422        let disabled: BTreeSet<String> = ["pdf".to_string()].into_iter().collect();
423
424        let filtered = filter_disabled_skills(skills, &disabled);
425        let ids: Vec<&str> = filtered.iter().map(|skill| skill.id.as_str()).collect();
426
427        assert_eq!(ids, vec!["pptx"]);
428    }
429}