Skip to main content

adk_skill/
injector.rs

1use crate::error::SkillResult;
2use crate::index::{load_skill_index, load_skill_index_with_extras};
3use crate::model::{SelectionPolicy, SkillIndex, SkillMatch};
4use crate::select::select_skills;
5use adk_core::{Content, Part};
6use adk_plugin::{Plugin, PluginConfig, PluginManager};
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9
10#[derive(Debug, Clone)]
11pub struct SkillInjectorConfig {
12    pub policy: SelectionPolicy,
13    pub max_injected_chars: usize,
14    /// Optional global skills directory (e.g. `~/.config/adk/skills/`).
15    /// Skills here are included in the index but project-local skills
16    /// take precedence when names collide.
17    pub global_skills_dir: Option<PathBuf>,
18    /// Additional directories to scan for skills.
19    pub extra_paths: Vec<PathBuf>,
20}
21
22impl Default for SkillInjectorConfig {
23    fn default() -> Self {
24        Self {
25            policy: SelectionPolicy::default(),
26            max_injected_chars: 2000,
27            global_skills_dir: None,
28            extra_paths: Vec::new(),
29        }
30    }
31}
32
33#[derive(Debug, Clone)]
34pub struct SkillInjector {
35    index: Arc<SkillIndex>,
36    config: SkillInjectorConfig,
37    /// Root this injector was built from, when it was built by scanning one.
38    ///
39    /// [`SkillInjector::reloaded`] needs it to rescan; an injector built from an existing index
40    /// has no root to rescan.
41    root: Option<PathBuf>,
42}
43
44impl SkillInjector {
45    pub fn from_root(root: impl AsRef<Path>, config: SkillInjectorConfig) -> SkillResult<Self> {
46        let mut extra_dirs: Vec<PathBuf> = config.extra_paths.clone();
47        if let Some(ref global) = config.global_skills_dir {
48            extra_dirs.push(global.clone());
49        }
50        let index = if extra_dirs.is_empty() {
51            load_skill_index(root.as_ref())?
52        } else {
53            load_skill_index_with_extras(root.as_ref(), &extra_dirs)?
54        };
55        Ok(Self { index: Arc::new(index), config, root: Some(root.as_ref().to_path_buf()) })
56    }
57
58    pub fn from_index(index: SkillIndex, config: SkillInjectorConfig) -> Self {
59        Self { index: Arc::new(index), config, root: None }
60    }
61
62    /// Rescans the root and returns an injector over the refreshed index.
63    ///
64    /// The index is snapshotted at construction, so a skill written after this injector was built
65    /// — by [`SkillWriter`](crate::SkillWriter), or by anything else on disk — is invisible to it.
66    ///
67    /// This returns a new injector rather than mutating in place because
68    /// [`build_plugin`](Self::build_plugin) captures the index by handle: a plugin already handed
69    /// to a runner keeps using the index it was built from. To pick up new skills, call this and
70    /// then rebuild the plugin from the result.
71    ///
72    /// # Errors
73    ///
74    /// Returns [`SkillError::Validation`](crate::SkillError::Validation) if this injector was
75    /// built with [`from_index`](Self::from_index) and so has no root to rescan, or a load error
76    /// if rescanning fails.
77    ///
78    /// # Example
79    ///
80    /// ```rust,ignore
81    /// let injector = injector.reloaded()?;
82    /// let plugin = injector.build_plugin("skills");
83    /// ```
84    pub fn reloaded(&self) -> SkillResult<Self> {
85        let Some(ref root) = self.root else {
86            return Err(crate::error::SkillError::Validation(
87                "this SkillInjector was built from an existing index with `from_index`, so there \
88                 is no root to rescan. Build it with `from_root` to reload."
89                    .to_string(),
90            ));
91        };
92
93        Self::from_root(root, self.config.clone())
94    }
95
96    /// The root this injector rescans, when it was built from one.
97    pub fn root(&self) -> Option<&Path> {
98        self.root.as_deref()
99    }
100
101    pub fn index(&self) -> &SkillIndex {
102        self.index.as_ref()
103    }
104
105    pub fn policy(&self) -> &SelectionPolicy {
106        &self.config.policy
107    }
108
109    pub fn max_injected_chars(&self) -> usize {
110        self.config.max_injected_chars
111    }
112
113    pub fn build_plugin(&self, name: impl Into<String>) -> Plugin {
114        let plugin_name = name.into();
115        let index = self.index.clone();
116        let policy = self.config.policy.clone();
117        let max_injected_chars = self.config.max_injected_chars;
118
119        Plugin::new(PluginConfig {
120            name: plugin_name,
121            on_user_message: Some(Box::new(move |_ctx, mut content| {
122                let index = index.clone();
123                let policy = policy.clone();
124                Box::pin(async move {
125                    let injected = apply_skill_injection(
126                        &mut content,
127                        index.as_ref(),
128                        &policy,
129                        max_injected_chars,
130                    );
131                    Ok(if injected.is_some() { Some(content) } else { None })
132                })
133            })),
134            ..Default::default()
135        })
136    }
137
138    pub fn build_plugin_manager(&self, name: impl Into<String>) -> PluginManager {
139        PluginManager::new(vec![self.build_plugin(name)])
140    }
141}
142
143/// Selects the top-scoring skill for a query and returns its formatted prompt block.
144///
145/// Returns `None` if no skill meets the selection criteria. The prompt block is
146/// truncated to `max_injected_chars` and wrapped in a `[skill:<name>]` section
147/// suitable for prepending to a user message.
148pub fn select_skill_prompt_block(
149    index: &SkillIndex,
150    query: &str,
151    policy: &SelectionPolicy,
152    max_injected_chars: usize,
153) -> Option<(SkillMatch, String)> {
154    let top = select_skills(index, query, policy).into_iter().next()?;
155    let matched = index.find_by_id(&top.skill.id)?;
156    let prompt_block = matched.engineer_prompt_block(max_injected_chars);
157    Some((top, prompt_block))
158}
159
160/// Injects the best-matching skill prompt into a user [`Content`] message.
161///
162/// Extracts the text from `content`, selects the top skill from `index`, and
163/// prepends its prompt block to the first text part. Returns the matched skill
164/// on success, or `None` if the content is not a user message, the index is
165/// empty, or no skill meets the selection threshold.
166pub fn apply_skill_injection(
167    content: &mut Content,
168    index: &SkillIndex,
169    policy: &SelectionPolicy,
170    max_injected_chars: usize,
171) -> Option<SkillMatch> {
172    if content.role != "user" || index.is_empty() {
173        return None;
174    }
175
176    let original_text = extract_text(content);
177    if original_text.trim().is_empty() {
178        return None;
179    }
180
181    let (top, prompt_block) =
182        select_skill_prompt_block(index, &original_text, policy, max_injected_chars)?;
183    let injected_text = format!("{prompt_block}\n\n{original_text}");
184
185    if let Some(Part::Text { text }) =
186        content.parts.iter_mut().find(|part| matches!(part, Part::Text { .. }))
187    {
188        *text = injected_text;
189    } else {
190        content.parts.insert(0, Part::Text { text: injected_text });
191    }
192
193    Some(top)
194}
195
196fn extract_text(content: &Content) -> String {
197    content
198        .parts
199        .iter()
200        .filter_map(|p| match p {
201            Part::Text { text } => Some(text.as_str()),
202            _ => None,
203        })
204        .collect::<Vec<_>>()
205        .join("\n")
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::index::load_skill_index;
212    use std::fs;
213
214    #[test]
215    fn injects_top_skill_into_user_message() {
216        let temp = tempfile::tempdir().unwrap();
217        let root = temp.path();
218        fs::create_dir_all(root.join(".skills")).unwrap();
219
220        fs::write(
221            root.join(".skills/search.md"),
222            "---\nname: search\ndescription: Search code\n---\nUse rg first.",
223        )
224        .unwrap();
225
226        let index = load_skill_index(root).unwrap();
227        let policy = SelectionPolicy { top_k: 1, min_score: 0.1, ..SelectionPolicy::default() };
228
229        let mut content = Content::new("user").with_text("Please search this repository quickly");
230        let matched = apply_skill_injection(&mut content, &index, &policy, 1000);
231
232        assert!(matched.is_some());
233        let injected = content.parts[0].text().unwrap();
234        assert!(injected.contains("[skill:search]"));
235        assert!(injected.contains("Use rg first."));
236    }
237}