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 pub global_skills_dir: Option<PathBuf>,
18 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: 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 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 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
143pub 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
160pub 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}