Skip to main content

adk_skill/
coordinator.rs

1//! # Context Coordinator
2//!
3//! This module implements the **Context Engineering Pipeline** for `adk-skill`.
4//! It bridges the gap between skill *selection* (scoring) and skill *execution* (tool binding),
5//! guaranteeing that an agent never receives instructions to use a tool that isn't bound.
6//!
7//! ## The Problem
8//! Injecting a skill's body into a prompt is insufficient. If the skill says "use `transfer_call`"
9//! but the tool isn't registered, the LLM will hallucinate the action — the "Phantom Tool" failure.
10//!
11//! ## The Solution
12//! The `ContextCoordinator` runs a three-stage pipeline:
13//! 1. **Selection** — `select_skills` scores all skills against the query.
14//! 2. **Validation** — Checks that the skill's `allowed-tools` exist in the host's `ToolRegistry`.
15//! 3. **Context Engineering** — Constructs a `SkillContext` with both the system instruction and
16//!    the resolved `Vec<Arc<dyn Tool>>`, delivered as a single atomic unit.
17//!
18//! Host applications provide a [`ToolRegistry`] implementation to map tool names to concrete
19//! instances. See [`DESIGN.md`](../DESIGN.md) for the full architectural rationale.
20
21use 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/// The output of the context engineering pipeline.
28///
29/// Encapsulates both the LLM's cognitive frame (instructions) and its physical
30/// capabilities (tools) as a single, atomic unit. This guarantees that the agent
31/// never receives a prompt telling it to use a tool that isn't bound.
32#[derive(Clone)]
33pub struct SkillContext {
34    /// The inner validated context (instruction + tools).
35    pub inner: ResolvedContext,
36    /// The score and metadata that triggered this context, for observability.
37    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// ToolRegistry is now in adk_core
58
59/// Configuration for the `ContextCoordinator`.
60#[derive(Debug, Clone)]
61pub struct CoordinatorConfig {
62    /// The selection policy used for scoring skills.
63    pub policy: SelectionPolicy,
64    /// Maximum characters to include from the skill body in the system instruction.
65    pub max_instruction_chars: usize,
66    /// How to handle skills that request unavailable tools.
67    pub validation_mode: ValidationMode,
68}
69
70/// Defines a strategy for resolving a skill into a context.
71#[derive(Debug, Clone)]
72pub enum ResolutionStrategy {
73    /// Resolve by exact skill name. Fails if name not found.
74    ByName(String),
75    /// Resolve by scoring against a user query. Fails if no skill meets threshold.
76    ByQuery(String),
77    /// Resolve by looking for a skill with a specific tag (e.g., "fallback", "default").
78    /// Picks the best-scored match among skills with this tag.
79    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
92/// The Context Engineering Engine for `adk-skill`.
93///
94/// Orchestrates the full pipeline: scoring → tool validation → context construction.
95/// Guarantees that the emitted `SkillContext` always has its `active_tools` aligned
96/// with what the `system_instruction` tells the LLM it can do.
97///
98/// # Pipeline
99/// 1. **Selection**: Scores skills against the query using `select_skills`.
100/// 2. **Validation**: Checks that requested `allowed-tools` exist in the `ToolRegistry`.
101/// 3. **Context Engineering**: Constructs a structured `system_instruction` from the body.
102/// 4. **Emission**: Returns a safe `SkillContext` or `None` if no valid match.
103pub struct ContextCoordinator {
104    index: Arc<SkillIndex>,
105    registry: Arc<dyn ToolRegistry>,
106    config: CoordinatorConfig,
107}
108
109impl ContextCoordinator {
110    /// Create a new coordinator from a skill index, tool registry, and config.
111    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    /// Build a `SkillContext` for the given query.
120    ///
121    /// Runs the full pipeline: score → validate tools → engineer context.
122    /// Returns `None` if no skill meets the policy threshold or tool validation fails.
123    pub fn build_context(&self, query: &str) -> Option<SkillContext> {
124        // 1. Score all skills and get candidates (top_k)
125        let candidates = select_skills(&self.index, query, &self.config.policy);
126
127        // 2. Try each candidate in rank order
128        for candidate in candidates {
129            match self.try_resolve(&candidate) {
130                Ok(context) => return Some(context),
131                Err(_) => continue, // In strict mode, try next candidate
132            }
133        }
134
135        None
136    }
137
138    /// Build a `SkillContext` for a skill looked up by exact name.
139    ///
140    /// This bypasses scoring entirely and is useful when the caller already
141    /// knows which skill to load (e.g., from a config field).
142    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    /// Resolve a `SkillContext` using a prioritized list of strategies.
151    ///
152    /// This is the "Standard-Compliant" entry point for context resolution.
153    /// It attempts each strategy in order and returns the first successful `SkillContext`.
154    ///
155    /// # Example
156    /// ```rust,ignore
157    /// coordinator.resolve(&[
158    ///     ResolutionStrategy::ByName("emergency".into()),
159    ///     ResolutionStrategy::ByQuery("I smell gas".into()),
160    ///     ResolutionStrategy::ByTag("fallback".into()),
161    /// ]);
162    /// ```
163    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                    // Filter index by tag, then score against an empty/generic query
170                    let candidates = select_skills(
171                        &self.index,
172                        "", // Generic query for tag matching
173                        &SelectionPolicy {
174                            include_tags: vec![tag.clone()],
175                            top_k: 1,
176                            min_score: 0.0, // Tags are binary, score doesn't matter much here
177                            ..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    /// Attempt to resolve tools and build a context for a single candidate.
192    fn try_resolve(&self, candidate: &SkillMatch) -> SkillResult<SkillContext> {
193        let allowed = &candidate.skill.allowed_tools;
194
195        // 2a. Resolve tools from registry
196        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        // 2b. Validate based on mode
208        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                    // Continue with partial tools — missing tools are silently omitted.
218                    // In production, consumers should monitor `provenance.skill.allowed_tools`
219                    // against `active_tools` to detect gaps.
220                }
221            }
222        }
223
224        // 3. Engineer the system instruction
225        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    // -- Test Tool --
251    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    // -- Test Registry --
273    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        // 1. Name match should work
419        let ctx = coordinator.resolve(&[ResolutionStrategy::ByName("emergency".into())]);
420        assert!(ctx.is_some());
421
422        // 2. Query match should work
423        let ctx = coordinator.resolve(&[ResolutionStrategy::ByQuery("gas emergency".into())]);
424        assert!(ctx.is_some());
425
426        // 3. Tag match should work
427        let ctx = coordinator.resolve(&[ResolutionStrategy::ByTag("plumber".into())]);
428        assert!(ctx.is_some(), "Should resolve by 'plumber' tag");
429
430        // 4. Multiple strategies, first success wins
431        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}