1use crate::error::SkillResult;
22use crate::model::{SelectionPolicy, SkillIndex, SkillMatch, SkillSummary};
23use crate::select::select_skills;
24pub use adk_core::{ResolvedContext, Tool, ToolRegistry, ValidationMode};
25use std::sync::Arc;
26
27#[derive(Clone)]
33pub struct SkillContext {
34 pub inner: ResolvedContext,
36 pub provenance: SkillMatch,
38}
39
40impl std::ops::Deref for SkillContext {
41 type Target = ResolvedContext;
42
43 fn deref(&self) -> &Self::Target {
44 &self.inner
45 }
46}
47
48impl std::fmt::Debug for SkillContext {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 f.debug_struct("SkillContext")
51 .field("inner", &self.inner)
52 .field("provenance", &self.provenance)
53 .finish()
54 }
55}
56
57#[derive(Debug, Clone)]
61pub struct CoordinatorConfig {
62 pub policy: SelectionPolicy,
64 pub max_instruction_chars: usize,
66 pub validation_mode: ValidationMode,
68}
69
70#[derive(Debug, Clone)]
72pub enum ResolutionStrategy {
73 ByName(String),
75 ByQuery(String),
77 ByTag(String),
80}
81
82impl Default for CoordinatorConfig {
83 fn default() -> Self {
84 Self {
85 policy: SelectionPolicy::default(),
86 max_instruction_chars: 8000,
87 validation_mode: ValidationMode::default(),
88 }
89 }
90}
91
92pub struct ContextCoordinator {
104 index: Arc<SkillIndex>,
105 registry: Arc<dyn ToolRegistry>,
106 config: CoordinatorConfig,
107}
108
109impl ContextCoordinator {
110 pub fn new(
112 index: Arc<SkillIndex>,
113 registry: Arc<dyn ToolRegistry>,
114 config: CoordinatorConfig,
115 ) -> Self {
116 Self { index, registry, config }
117 }
118
119 pub fn build_context(&self, query: &str) -> Option<SkillContext> {
124 let candidates = select_skills(&self.index, query, &self.config.policy);
126
127 for candidate in candidates {
129 match self.try_resolve(&candidate) {
130 Ok(context) => return Some(context),
131 Err(_) => continue, }
133 }
134
135 None
136 }
137
138 pub fn build_context_by_name(&self, name: &str) -> Option<SkillContext> {
143 let skill = self.index.find_by_name(name)?;
144 let summary = SkillSummary::from(skill);
145 let skill_match = SkillMatch { score: f32::MAX, skill: summary };
146
147 self.try_resolve(&skill_match).ok()
148 }
149
150 pub fn resolve(&self, strategies: &[ResolutionStrategy]) -> Option<SkillContext> {
164 for strategy in strategies {
165 let result = match strategy {
166 ResolutionStrategy::ByName(name) => self.build_context_by_name(name),
167 ResolutionStrategy::ByQuery(query) => self.build_context(query),
168 ResolutionStrategy::ByTag(tag) => {
169 let candidates = select_skills(
171 &self.index,
172 "", &SelectionPolicy {
174 include_tags: vec![tag.clone()],
175 top_k: 1,
176 min_score: 0.0, ..self.config.policy.clone()
178 },
179 );
180 candidates.first().and_then(|m| self.try_resolve(m).ok())
181 }
182 };
183
184 if let Some(ctx) = result {
185 return Some(ctx);
186 }
187 }
188 None
189 }
190
191 fn try_resolve(&self, candidate: &SkillMatch) -> SkillResult<SkillContext> {
193 let allowed = &candidate.skill.allowed_tools;
194
195 let mut active_tools: Vec<Arc<dyn Tool>> = Vec::new();
197 let mut missing: Vec<String> = Vec::new();
198
199 for tool_name in allowed {
200 if let Some(tool) = self.registry.resolve(tool_name) {
201 active_tools.push(tool);
202 } else {
203 missing.push(tool_name.clone());
204 }
205 }
206
207 if !missing.is_empty() {
209 match self.config.validation_mode {
210 ValidationMode::Strict => {
211 return Err(crate::error::SkillError::Validation(format!(
212 "Skill '{}' requires tools not in registry: {:?}",
213 candidate.skill.name, missing
214 )));
215 }
216 ValidationMode::Permissive => {
217 }
221 }
222 }
223
224 let matched_skill = self.index.find_by_id(&candidate.skill.id).ok_or_else(|| {
226 crate::error::SkillError::IndexError(format!(
227 "Matched skill not found in index: {}",
228 candidate.skill.name
229 ))
230 })?;
231
232 let system_instruction =
233 matched_skill.engineer_instruction(self.config.max_instruction_chars, &active_tools);
234
235 Ok(SkillContext {
236 inner: ResolvedContext { system_instruction, active_tools },
237 provenance: candidate.clone(),
238 })
239 }
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245 use crate::index::load_skill_index;
246 use async_trait::async_trait;
247 use serde_json::Value;
248 use std::fs;
249
250 struct MockTool {
252 tool_name: String,
253 }
254
255 #[async_trait]
256 impl Tool for MockTool {
257 fn name(&self) -> &str {
258 &self.tool_name
259 }
260 fn description(&self) -> &str {
261 "mock tool"
262 }
263 async fn execute(
264 &self,
265 _ctx: Arc<dyn adk_core::ToolContext>,
266 _args: Value,
267 ) -> adk_core::Result<Value> {
268 Ok(Value::Null)
269 }
270 }
271
272 struct TestRegistry {
274 available: Vec<String>,
275 }
276
277 impl ToolRegistry for TestRegistry {
278 fn resolve(&self, tool_name: &str) -> Option<Arc<dyn Tool>> {
279 if self.available.contains(&tool_name.to_string()) {
280 Some(Arc::new(MockTool { tool_name: tool_name.to_string() }))
281 } else {
282 None
283 }
284 }
285 }
286
287 fn setup_index(tools: &[&str]) -> (tempfile::TempDir, SkillIndex) {
288 let temp = tempfile::tempdir().unwrap();
289 let root = temp.path();
290 fs::create_dir_all(root.join(".skills")).unwrap();
291
292 let tools_yaml = if tools.is_empty() {
293 String::new()
294 } else {
295 let items: Vec<String> = tools.iter().map(|t| format!(" - {}", t)).collect();
296 format!("allowed-tools:\n{}\n", items.join("\n"))
297 };
298
299 fs::write(
300 root.join(".skills/emergency.md"),
301 format!(
302 "---\nname: emergency\ndescription: Handle gas and water emergencies\ntags:\n - plumber\n{}\n---\nYou are an emergency dispatcher. Route calls for gas leaks and floods.",
303 tools_yaml
304 ),
305 )
306 .unwrap();
307
308 let index = load_skill_index(root).unwrap();
309 (temp, index)
310 }
311
312 #[test]
313 fn build_context_scores_and_resolves_tools() {
314 let (_tmp, index) = setup_index(&["knowledge", "transfer_call"]);
315 let registry = TestRegistry { available: vec!["knowledge".into(), "transfer_call".into()] };
316
317 let coordinator = ContextCoordinator::new(
318 Arc::new(index),
319 Arc::new(registry),
320 CoordinatorConfig {
321 policy: SelectionPolicy { top_k: 1, min_score: 0.1, ..Default::default() },
322 ..Default::default()
323 },
324 );
325
326 let ctx = coordinator.build_context("gas emergency").unwrap();
327 assert_eq!(ctx.active_tools.len(), 2);
328 assert!(ctx.system_instruction.contains("[skill:emergency]"));
329 assert!(ctx.system_instruction.contains("knowledge, transfer_call"));
330 assert!(ctx.system_instruction.contains("emergency dispatcher"));
331 }
332
333 #[test]
334 fn strict_mode_rejects_missing_tools() {
335 let (_tmp, index) = setup_index(&["knowledge", "nonexistent_tool"]);
336 let registry = TestRegistry { available: vec!["knowledge".into()] };
337
338 let coordinator = ContextCoordinator::new(
339 Arc::new(index),
340 Arc::new(registry),
341 CoordinatorConfig {
342 policy: SelectionPolicy { top_k: 1, min_score: 0.1, ..Default::default() },
343 validation_mode: ValidationMode::Strict,
344 ..Default::default()
345 },
346 );
347
348 let ctx = coordinator.build_context("gas emergency");
349 assert!(ctx.is_none(), "Strict mode should reject skills with missing tools");
350 }
351
352 #[test]
353 fn permissive_mode_binds_available_tools() {
354 let (_tmp, index) = setup_index(&["knowledge", "nonexistent_tool"]);
355 let registry = TestRegistry { available: vec!["knowledge".into()] };
356
357 let coordinator = ContextCoordinator::new(
358 Arc::new(index),
359 Arc::new(registry),
360 CoordinatorConfig {
361 policy: SelectionPolicy { top_k: 1, min_score: 0.1, ..Default::default() },
362 validation_mode: ValidationMode::Permissive,
363 ..Default::default()
364 },
365 );
366
367 let ctx = coordinator.build_context("gas emergency").unwrap();
368 assert_eq!(ctx.active_tools.len(), 1);
369 assert_eq!(ctx.active_tools[0].name(), "knowledge");
370 }
371
372 #[test]
373 fn build_context_by_name_bypasses_scoring() {
374 let (_tmp, index) = setup_index(&["knowledge"]);
375 let registry = TestRegistry { available: vec!["knowledge".into()] };
376
377 let coordinator = ContextCoordinator::new(
378 Arc::new(index),
379 Arc::new(registry),
380 CoordinatorConfig::default(),
381 );
382
383 let ctx = coordinator.build_context_by_name("emergency").unwrap();
384 assert_eq!(ctx.active_tools.len(), 1);
385 assert!(ctx.system_instruction.contains("[skill:emergency]"));
386 }
387
388 #[test]
389 fn no_tools_skill_returns_empty_active_tools() {
390 let (_tmp, index) = setup_index(&[]);
391 let registry = TestRegistry { available: vec![] };
392
393 let coordinator = ContextCoordinator::new(
394 Arc::new(index),
395 Arc::new(registry),
396 CoordinatorConfig {
397 policy: SelectionPolicy { top_k: 1, min_score: 0.1, ..Default::default() },
398 ..Default::default()
399 },
400 );
401
402 let ctx = coordinator.build_context("gas emergency").unwrap();
403 assert!(ctx.active_tools.is_empty());
404 assert!(ctx.system_instruction.contains("emergency dispatcher"));
405 }
406
407 #[test]
408 fn resolve_cascades_through_strategies() {
409 let (_tmp, index) = setup_index(&["knowledge"]);
410 let registry = TestRegistry { available: vec!["knowledge".into()] };
411
412 let coordinator = ContextCoordinator::new(
413 Arc::new(index),
414 Arc::new(registry),
415 CoordinatorConfig::default(),
416 );
417
418 let ctx = coordinator.resolve(&[ResolutionStrategy::ByName("emergency".into())]);
420 assert!(ctx.is_some());
421
422 let ctx = coordinator.resolve(&[ResolutionStrategy::ByQuery("gas emergency".into())]);
424 assert!(ctx.is_some());
425
426 let ctx = coordinator.resolve(&[ResolutionStrategy::ByTag("plumber".into())]);
428 assert!(ctx.is_some(), "Should resolve by 'plumber' tag");
429
430 let ctx = coordinator.resolve(&[
432 ResolutionStrategy::ByName("nonexistent".into()),
433 ResolutionStrategy::ByTag("plumber".into()),
434 ]);
435 assert_eq!(ctx.unwrap().provenance.skill.name, "emergency");
436 }
437}