use crate::error::SkillResult;
use crate::model::{SelectionPolicy, SkillIndex, SkillMatch, SkillSummary};
use crate::select::select_skills;
pub use adk_core::{ResolvedContext, Tool, ToolRegistry, ValidationMode};
use std::sync::Arc;
#[derive(Clone)]
pub struct SkillContext {
pub inner: ResolvedContext,
pub provenance: SkillMatch,
}
impl std::ops::Deref for SkillContext {
type Target = ResolvedContext;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl std::fmt::Debug for SkillContext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SkillContext")
.field("inner", &self.inner)
.field("provenance", &self.provenance)
.finish()
}
}
#[derive(Debug, Clone)]
pub struct CoordinatorConfig {
pub policy: SelectionPolicy,
pub max_instruction_chars: usize,
pub validation_mode: ValidationMode,
}
#[derive(Debug, Clone)]
pub enum ResolutionStrategy {
ByName(String),
ByQuery(String),
ByTag(String),
}
impl Default for CoordinatorConfig {
fn default() -> Self {
Self {
policy: SelectionPolicy::default(),
max_instruction_chars: 8000,
validation_mode: ValidationMode::default(),
}
}
}
pub struct ContextCoordinator {
index: Arc<SkillIndex>,
registry: Arc<dyn ToolRegistry>,
config: CoordinatorConfig,
}
impl ContextCoordinator {
pub fn new(
index: Arc<SkillIndex>,
registry: Arc<dyn ToolRegistry>,
config: CoordinatorConfig,
) -> Self {
Self { index, registry, config }
}
pub fn build_context(&self, query: &str) -> Option<SkillContext> {
let candidates = select_skills(&self.index, query, &self.config.policy);
for candidate in candidates {
match self.try_resolve(&candidate) {
Ok(context) => return Some(context),
Err(_) => continue, }
}
None
}
pub fn build_context_by_name(&self, name: &str) -> Option<SkillContext> {
let skill = self.index.find_by_name(name)?;
let summary = SkillSummary::from(skill);
let skill_match = SkillMatch { score: f32::MAX, skill: summary };
self.try_resolve(&skill_match).ok()
}
pub fn resolve(&self, strategies: &[ResolutionStrategy]) -> Option<SkillContext> {
for strategy in strategies {
let result = match strategy {
ResolutionStrategy::ByName(name) => self.build_context_by_name(name),
ResolutionStrategy::ByQuery(query) => self.build_context(query),
ResolutionStrategy::ByTag(tag) => {
let candidates = select_skills(
&self.index,
"", &SelectionPolicy {
include_tags: vec![tag.clone()],
top_k: 1,
min_score: 0.0, ..self.config.policy.clone()
},
);
candidates.first().and_then(|m| self.try_resolve(m).ok())
}
};
if let Some(ctx) = result {
return Some(ctx);
}
}
None
}
fn try_resolve(&self, candidate: &SkillMatch) -> SkillResult<SkillContext> {
let allowed = &candidate.skill.allowed_tools;
let mut active_tools: Vec<Arc<dyn Tool>> = Vec::new();
let mut missing: Vec<String> = Vec::new();
for tool_name in allowed {
if let Some(tool) = self.registry.resolve(tool_name) {
active_tools.push(tool);
} else {
missing.push(tool_name.clone());
}
}
if !missing.is_empty() {
match self.config.validation_mode {
ValidationMode::Strict => {
return Err(crate::error::SkillError::Validation(format!(
"Skill '{}' requires tools not in registry: {:?}",
candidate.skill.name, missing
)));
}
ValidationMode::Permissive => {
}
}
}
let matched_skill = self.index.find_by_id(&candidate.skill.id).ok_or_else(|| {
crate::error::SkillError::IndexError(format!(
"Matched skill not found in index: {}",
candidate.skill.name
))
})?;
let system_instruction =
matched_skill.engineer_instruction(self.config.max_instruction_chars, &active_tools);
Ok(SkillContext {
inner: ResolvedContext { system_instruction, active_tools },
provenance: candidate.clone(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::index::load_skill_index;
use async_trait::async_trait;
use serde_json::Value;
use std::fs;
struct MockTool {
tool_name: String,
}
#[async_trait]
impl Tool for MockTool {
fn name(&self) -> &str {
&self.tool_name
}
fn description(&self) -> &str {
"mock tool"
}
async fn execute(
&self,
_ctx: Arc<dyn adk_core::ToolContext>,
_args: Value,
) -> adk_core::Result<Value> {
Ok(Value::Null)
}
}
struct TestRegistry {
available: Vec<String>,
}
impl ToolRegistry for TestRegistry {
fn resolve(&self, tool_name: &str) -> Option<Arc<dyn Tool>> {
if self.available.contains(&tool_name.to_string()) {
Some(Arc::new(MockTool { tool_name: tool_name.to_string() }))
} else {
None
}
}
}
fn setup_index(tools: &[&str]) -> (tempfile::TempDir, SkillIndex) {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
fs::create_dir_all(root.join(".skills")).unwrap();
let tools_yaml = if tools.is_empty() {
String::new()
} else {
let items: Vec<String> = tools.iter().map(|t| format!(" - {}", t)).collect();
format!("allowed-tools:\n{}\n", items.join("\n"))
};
fs::write(
root.join(".skills/emergency.md"),
format!(
"---\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.",
tools_yaml
),
)
.unwrap();
let index = load_skill_index(root).unwrap();
(temp, index)
}
#[test]
fn build_context_scores_and_resolves_tools() {
let (_tmp, index) = setup_index(&["knowledge", "transfer_call"]);
let registry = TestRegistry { available: vec!["knowledge".into(), "transfer_call".into()] };
let coordinator = ContextCoordinator::new(
Arc::new(index),
Arc::new(registry),
CoordinatorConfig {
policy: SelectionPolicy { top_k: 1, min_score: 0.1, ..Default::default() },
..Default::default()
},
);
let ctx = coordinator.build_context("gas emergency").unwrap();
assert_eq!(ctx.active_tools.len(), 2);
assert!(ctx.system_instruction.contains("[skill:emergency]"));
assert!(ctx.system_instruction.contains("knowledge, transfer_call"));
assert!(ctx.system_instruction.contains("emergency dispatcher"));
}
#[test]
fn strict_mode_rejects_missing_tools() {
let (_tmp, index) = setup_index(&["knowledge", "nonexistent_tool"]);
let registry = TestRegistry { available: vec!["knowledge".into()] };
let coordinator = ContextCoordinator::new(
Arc::new(index),
Arc::new(registry),
CoordinatorConfig {
policy: SelectionPolicy { top_k: 1, min_score: 0.1, ..Default::default() },
validation_mode: ValidationMode::Strict,
..Default::default()
},
);
let ctx = coordinator.build_context("gas emergency");
assert!(ctx.is_none(), "Strict mode should reject skills with missing tools");
}
#[test]
fn permissive_mode_binds_available_tools() {
let (_tmp, index) = setup_index(&["knowledge", "nonexistent_tool"]);
let registry = TestRegistry { available: vec!["knowledge".into()] };
let coordinator = ContextCoordinator::new(
Arc::new(index),
Arc::new(registry),
CoordinatorConfig {
policy: SelectionPolicy { top_k: 1, min_score: 0.1, ..Default::default() },
validation_mode: ValidationMode::Permissive,
..Default::default()
},
);
let ctx = coordinator.build_context("gas emergency").unwrap();
assert_eq!(ctx.active_tools.len(), 1);
assert_eq!(ctx.active_tools[0].name(), "knowledge");
}
#[test]
fn build_context_by_name_bypasses_scoring() {
let (_tmp, index) = setup_index(&["knowledge"]);
let registry = TestRegistry { available: vec!["knowledge".into()] };
let coordinator = ContextCoordinator::new(
Arc::new(index),
Arc::new(registry),
CoordinatorConfig::default(),
);
let ctx = coordinator.build_context_by_name("emergency").unwrap();
assert_eq!(ctx.active_tools.len(), 1);
assert!(ctx.system_instruction.contains("[skill:emergency]"));
}
#[test]
fn no_tools_skill_returns_empty_active_tools() {
let (_tmp, index) = setup_index(&[]);
let registry = TestRegistry { available: vec![] };
let coordinator = ContextCoordinator::new(
Arc::new(index),
Arc::new(registry),
CoordinatorConfig {
policy: SelectionPolicy { top_k: 1, min_score: 0.1, ..Default::default() },
..Default::default()
},
);
let ctx = coordinator.build_context("gas emergency").unwrap();
assert!(ctx.active_tools.is_empty());
assert!(ctx.system_instruction.contains("emergency dispatcher"));
}
#[test]
fn resolve_cascades_through_strategies() {
let (_tmp, index) = setup_index(&["knowledge"]);
let registry = TestRegistry { available: vec!["knowledge".into()] };
let coordinator = ContextCoordinator::new(
Arc::new(index),
Arc::new(registry),
CoordinatorConfig::default(),
);
let ctx = coordinator.resolve(&[ResolutionStrategy::ByName("emergency".into())]);
assert!(ctx.is_some());
let ctx = coordinator.resolve(&[ResolutionStrategy::ByQuery("gas emergency".into())]);
assert!(ctx.is_some());
let ctx = coordinator.resolve(&[ResolutionStrategy::ByTag("plumber".into())]);
assert!(ctx.is_some(), "Should resolve by 'plumber' tag");
let ctx = coordinator.resolve(&[
ResolutionStrategy::ByName("nonexistent".into()),
ResolutionStrategy::ByTag("plumber".into()),
]);
assert_eq!(ctx.unwrap().provenance.skill.name, "emergency");
}
}