use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::Arc; use crate::config::{ AgentOrchestrationConfig, LLMProviderConfig, ModelConfig, ProviderConfig, expand_path, }; use crate::skills::SkillsLoader; use crate::tools::ToolRegistry; use super::definition::{AgentDefinition, AgentDefinitionError, parse_definition}; /// Fallback delegation target for Agents that do not declare a `delegates` /// field. The built-in `general-purpose` definition is released on first /// run, so the default works out of the box; if it is deleted, an Agent with /// no explicit `delegates` simply cannot delegate further. pub const DEFAULT_DELEGATE: &str = "general-purpose"; #[derive(Debug, thiserror::Error)] pub enum AgentCatalogError { #[error("invalid Agent orchestration config: {0}")] Config(String), #[error(transparent)] Definition(#[from] AgentDefinitionError), #[error("Agent definition directory error: {0}")] Directory(String), #[error("Agent '{agent}' references unknown Provider profile '{profile}'")] UnknownProfile { agent: String, profile: String }, #[error("Agent '{agent}' references unknown provider '{provider}'")] UnknownProvider { agent: String, provider: String }, #[error("Agent '{agent}' references unknown model '{model}'")] UnknownModel { agent: String, model: String }, #[error("Agent '{agent}' references invalid tool '{tool}': {reason}")] InvalidTool { agent: String, tool: String, reason: String, }, #[error("Agent '{agent}' references unknown delegate '{target}'")] UnknownDelegate { agent: String, target: String }, #[error("Agent '{agent}' references unknown skill '{skill}'")] UnknownSkill { agent: String, skill: String }, } #[derive(Debug)] pub struct AgentCatalog { definitions: BTreeMap>, runtime_generation: u64, max_tree_depth: u16, max_runs_per_tree: usize, } impl AgentCatalog { pub fn legacy() -> Self { Self { definitions: BTreeMap::new(), runtime_generation: 0, max_tree_depth: 4, max_runs_per_tree: 16, } } #[allow(clippy::too_many_arguments)] pub fn load( config: &AgentOrchestrationConfig, config_dir: &Path, provider_profiles: &HashMap, providers: &HashMap, models: &HashMap, workspace_dir: &Path, tools: &ToolRegistry, skills_loader: &SkillsLoader, runtime_generation: u64, ) -> Result { config.validate().map_err(AgentCatalogError::Config)?; let trusted_root = config_dir.canonicalize().map_err(|error| { AgentCatalogError::Directory(format!("{}: {error}", config_dir.display())) })?; let configured = expand_path(&config.definitions_dir); let definitions_dir = if configured.is_absolute() { configured } else { trusted_root.join(configured) }; let definitions_dir = definitions_dir.canonicalize().map_err(|error| { AgentCatalogError::Directory(format!("{}: {error}", definitions_dir.display())) })?; if !definitions_dir.starts_with(&trusted_root) { return Err(AgentCatalogError::Directory(format!( "{} escapes trusted config directory {}", definitions_dir.display(), trusted_root.display() ))); } let mut paths = definition_paths(&definitions_dir)?; paths.sort(); let loaded_skills: HashSet = skills_loader .list_skills() .into_iter() .map(|(name, _)| name) .collect(); let mut definitions = BTreeMap::new(); for path in paths { let spec = read_provider_spec(&path)?; // Disabled definitions stay on disk for the management UI but // never enter the active catalog. if !spec.enabled { continue; } let provider = resolve_provider(&spec, provider_profiles, providers, models, workspace_dir)?; let definition = Arc::new(parse_definition(&path, Arc::new(provider))?); if definitions.contains_key(&definition.id) { return Err(AgentCatalogError::Config(format!( "duplicate Agent id '{}'", definition.id ))); } validate_definition_tools(&definition, tools)?; for skill in &definition.skills { if !loaded_skills.contains(skill) { return Err(AgentCatalogError::UnknownSkill { agent: definition.id.clone(), skill: skill.clone(), }); } } if !definition.skills.is_empty() && !definition.tools.iter().any(|tool| tool == "get_skill") { return Err(AgentCatalogError::InvalidTool { agent: definition.id.clone(), tool: "get_skill".to_string(), reason: "skills require get_skill in the definition tool list".to_string(), }); } definitions.insert(definition.id.clone(), definition); } for definition in definitions.values() { let Some(delegates) = definition.delegates.as_deref() else { continue; }; // A `*` entry means "any other Agent" and skips target validation. if delegates.iter().any(|target| target == "*") { continue; } for target in delegates { if !definitions.contains_key(target) { return Err(AgentCatalogError::UnknownDelegate { agent: definition.id.clone(), target: target.clone(), }); } } } Ok(Self { definitions, runtime_generation, max_tree_depth: config.max_tree_depth, max_runs_per_tree: config.max_runs_per_tree, }) } pub fn runtime_generation(&self) -> u64 { self.runtime_generation } pub fn max_tree_depth(&self) -> u16 { self.max_tree_depth } pub fn max_runs_per_tree(&self) -> usize { self.max_runs_per_tree } pub fn get(&self, id: &str) -> Option> { self.definitions.get(id).cloned() } /// ROOT may delegate to any named Agent. ROOT has no self to exclude. pub fn root_can_delegate(&self, target: &str) -> bool { self.definitions.contains_key(target) } /// Whether `caller` may delegate to `target`, honouring the /// default/`*`/empty/list semantics. Self-delegation is never allowed. pub fn can_delegate(&self, caller: &str, target: &str) -> bool { if caller == target { return false; } let Some(definition) = self.definitions.get(caller) else { return false; }; match definition.delegates.as_deref() { None => target == DEFAULT_DELEGATE && self.definitions.contains_key(target), Some(list) if list.iter().any(|entry| entry == "*") => { self.definitions.contains_key(target) } Some(list) => list.iter().any(|entry| entry == target), } } /// Concrete delegation targets for `agent_id` after applying the /// default/`*`/empty/list semantics. Used to scope the model-visible /// `delegate` tool schema. Self is never a valid target. pub fn delegate_targets(&self, agent_id: &str) -> Vec { let Some(definition) = self.definitions.get(agent_id) else { return Vec::new(); }; match definition.delegates.as_deref() { None => { if agent_id != DEFAULT_DELEGATE && self.definitions.contains_key(DEFAULT_DELEGATE) { vec![DEFAULT_DELEGATE.to_string()] } else { Vec::new() } } Some(list) if list.iter().any(|entry| entry == "*") => self .definitions .keys() .filter(|id| id.as_str() != agent_id) .cloned() .collect(), Some(list) => list .iter() .filter(|entry| entry.as_str() != agent_id) .cloned() .collect(), } } /// Every named Agent, exposed to the root `delegate` tool schema. pub fn root_targets(&self) -> Vec> { self.definitions.values().cloned().collect() } } fn definition_paths(directory: &Path) -> Result, AgentCatalogError> { let mut paths = Vec::new(); let entries = std::fs::read_dir(directory).map_err(|error| { AgentCatalogError::Directory(format!("{}: {error}", directory.display())) })?; for entry in entries { let entry = entry.map_err(|error| AgentCatalogError::Directory(error.to_string()))?; let path = entry.path(); if path.extension().and_then(|value| value.to_str()) != Some("md") { continue; } let canonical = path.canonicalize().map_err(|error| { AgentCatalogError::Directory(format!("{}: {error}", path.display())) })?; if !canonical.starts_with(directory) { return Err(AgentCatalogError::Directory(format!( "{} escapes definitions directory", path.display() ))); } paths.push(path); } Ok(paths) } /// Provider/model fields read from a definition's frontmatter before the /// full definition is parsed, so the catalog can resolve the provider config /// and skip disabled definitions in one pass. struct ProviderSpec { id: String, llm_profile: Option, provider: Option, model: Option, token_limit: Option, max_tool_iterations: Option, enabled: bool, } fn read_provider_spec(path: &Path) -> Result { let metadata = std::fs::symlink_metadata(path).map_err(|source| AgentDefinitionError::Io { path: path.display().to_string(), source, })?; if !metadata.file_type().is_file() || metadata.file_type().is_symlink() { return Err(AgentDefinitionError::Invalid(format!( "{} must be a regular non-symlink file", path.display() )) .into()); } if metadata.len() > super::definition::MAX_DEFINITION_FILE_BYTES { return Err(AgentDefinitionError::Invalid(format!( "{} exceeds the {} byte limit", path.display(), super::definition::MAX_DEFINITION_FILE_BYTES )) .into()); } let content = std::fs::read_to_string(path).map_err(|source| AgentDefinitionError::Io { path: path.display().to_string(), source, })?; let normalized = content.replace("\r\n", "\n"); let rest = normalized.strip_prefix("---\n").ok_or_else(|| { AgentDefinitionError::Invalid(format!( "{} must start with a standalone --- line", path.display() )) })?; let (yaml, _) = rest.split_once("\n---\n").ok_or_else(|| { AgentDefinitionError::Invalid(format!( "{} has no closing frontmatter delimiter", path.display() )) })?; #[derive(serde::Deserialize)] struct Spec { id: String, llm_profile: Option, provider: Option, model: Option, token_limit: Option, max_tool_iterations: Option, #[serde(default = "crate::agent::definition::default_true")] enabled: bool, } let parsed: Spec = serde_yaml::from_str(yaml).map_err(AgentDefinitionError::Yaml)?; Ok(ProviderSpec { id: parsed.id, llm_profile: parsed.llm_profile, provider: parsed.provider, model: parsed.model, token_limit: parsed.token_limit, max_tool_iterations: parsed.max_tool_iterations, enabled: parsed.enabled, }) } fn resolve_provider( spec: &ProviderSpec, provider_profiles: &HashMap, providers: &HashMap, models: &HashMap, workspace_dir: &Path, ) -> Result { let inline = spec.provider.is_some() || spec.model.is_some(); if inline { let provider_name = spec.provider.as_deref().unwrap_or_default(); let model_name = spec.model.as_deref().unwrap_or_default(); let provider = providers .get(provider_name) .ok_or_else(|| AgentCatalogError::UnknownProvider { agent: spec.id.clone(), provider: provider_name.to_string(), })?; let model = models .get(model_name) .ok_or_else(|| AgentCatalogError::UnknownModel { agent: spec.id.clone(), model: model_name.to_string(), })?; return Ok(LLMProviderConfig { provider_type: provider.provider_type.clone(), name: provider_name.to_string(), base_url: provider.base_url.clone(), api_key: provider.api_key.clone(), extra_headers: provider.extra_headers.clone(), model_id: model.model_id.clone(), temperature: model.temperature, max_tokens: model.max_tokens, model_extra: model.extra.clone(), max_tool_iterations: spec.max_tool_iterations.unwrap_or(99), token_limit: spec.token_limit.unwrap_or(128_000), workspace_dir: workspace_dir.to_path_buf(), input_types: model.input_type.clone(), price_input_per_million: None, price_output_per_million: None, }); } let profile = spec.llm_profile.as_deref().unwrap_or_default(); provider_profiles .get(profile) .cloned() .ok_or_else(|| AgentCatalogError::UnknownProfile { agent: spec.id.clone(), profile: profile.to_string(), }) } fn validate_definition_tools( definition: &AgentDefinition, tools: &ToolRegistry, ) -> Result<(), AgentCatalogError> { for name in &definition.tools { let tool = tools .get(name) .ok_or_else(|| AgentCatalogError::InvalidTool { agent: definition.id.clone(), tool: name.clone(), reason: "tool is not registered in the prepared runtime".to_string(), })?; // Which tools a named Agent receives is decided by its definition // file alone. Runtime-injected tools (delegate/emit_signal/ // get_skill/agent_task) are assembled from dedicated fields // (delegates/signal/skills) and must never appear in `tools`; // `get_skill` is the one exception: listing it turns on the scoped // skill wrapper, which is injected at resolve time. if tool.runtime_injected() && name != "get_skill" { return Err(AgentCatalogError::InvalidTool { agent: definition.id.clone(), tool: name.clone(), reason: "runtime-injected tool cannot be declared in a definition".to_string(), }); } } Ok(()) } #[cfg(test)] mod tests { use super::*; use crate::tools::{CalculatorTool, GetSkillTool}; fn provider() -> LLMProviderConfig { LLMProviderConfig { provider_type: "openai".to_string(), name: "test".to_string(), base_url: "https://example.invalid/v1".to_string(), api_key: "test".to_string(), extra_headers: HashMap::new(), model_id: "test-model".to_string(), temperature: None, max_tokens: None, model_extra: HashMap::new(), max_tool_iterations: 99, token_limit: 4096, workspace_dir: std::env::temp_dir(), input_types: vec!["text".to_string()], price_input_per_million: None, price_output_per_million: None, } } fn config() -> AgentOrchestrationConfig { AgentOrchestrationConfig { definitions_dir: "agents".to_string(), ..Default::default() } } /// `delegates`: `None` omits the field (default semantics), `Some([])` /// writes an explicit empty list, `Some(list)` writes the entries. fn write_agent(root: &Path, id: &str, tools: &[&str], delegates: Option<&[&str]>) { let tools = (!tools.is_empty()).then(|| { format!( "tools:\n{}\n", tools .iter() .map(|name| format!(" - {name}")) .collect::>() .join("\n") ) }); let delegates = delegates.map(|delegates| { if delegates.is_empty() { "delegates: []\n".to_string() } else { format!( "delegates:\n{}\n", delegates .iter() .map(|name| { if *name == "*" { " - \"*\"".to_string() } else { format!(" - {name}") } }) .collect::>() .join("\n") ) } }); std::fs::write( root.join("agents").join(format!("{id}.md")), format!( "---\nid: {id}\ndescription: {id} role\nllm_profile: research\n{}{}---\n# Role\n\nDo the assigned work.\n", tools.unwrap_or_default(), delegates.unwrap_or_default() ), ) .unwrap(); } #[test] fn catalog_loads_provider_tools_and_delegation_graph() { let root = tempfile::tempdir().unwrap(); std::fs::create_dir(root.path().join("agents")).unwrap(); write_agent(root.path(), "researcher", &["calculator"], Some(&["reviewer"])); write_agent(root.path(), "reviewer", &["calculator"], None); let tools = ToolRegistry::new(); tools.register(CalculatorTool::new()); let loader = SkillsLoader::new_for_testing( root.path().join("skills"), root.path().join("external-skills"), ); let profiles = HashMap::from([("research".to_string(), provider())]); let catalog = AgentCatalog::load( &config(), root.path(), &profiles, &HashMap::new(), &HashMap::new(), root.path(), &tools, &loader, 7, ) .unwrap(); assert!(catalog.root_can_delegate("researcher")); assert!(catalog.can_delegate("researcher", "reviewer")); assert_eq!( catalog.get("researcher").unwrap().provider_config.model_id, "test-model" ); assert_eq!(catalog.runtime_generation(), 7); } #[test] fn delegation_semantics_default_empty_any_and_list() { let root = tempfile::tempdir().unwrap(); std::fs::create_dir(root.path().join("agents")).unwrap(); write_agent(root.path(), "general-purpose", &[], None); write_agent(root.path(), "researcher", &[], None); write_agent(root.path(), "reviewer", &[], Some(&[])); write_agent(root.path(), "coder", &[], Some(&["*"])); write_agent(root.path(), "writer", &[], Some(&["reviewer"])); let tools = ToolRegistry::new(); let loader = SkillsLoader::new_for_testing( root.path().join("skills"), root.path().join("external-skills"), ); let profiles = HashMap::from([("research".to_string(), provider())]); let catalog = AgentCatalog::load( &config(), root.path(), &profiles, &HashMap::new(), &HashMap::new(), root.path(), &tools, &loader, 1, ) .unwrap(); // ROOT may delegate to every named Agent. for id in ["general-purpose", "researcher", "reviewer", "coder", "writer"] { assert!(catalog.root_can_delegate(id), "ROOT -> {id}"); } // Unset `delegates` defaults to general-purpose only. assert!(catalog.can_delegate("researcher", "general-purpose")); assert!(!catalog.can_delegate("researcher", "reviewer")); assert_eq!(catalog.delegate_targets("researcher"), ["general-purpose"]); // general-purpose itself has no further delegate (self excluded). assert!(!catalog.can_delegate("general-purpose", "researcher")); assert!(catalog.delegate_targets("general-purpose").is_empty()); // Explicit empty list forbids delegation. assert!(!catalog.can_delegate("reviewer", "researcher")); assert!(!catalog.can_delegate("reviewer", "general-purpose")); assert!(catalog.delegate_targets("reviewer").is_empty()); // `*` allows any other Agent, never self. assert!(catalog.can_delegate("coder", "researcher")); assert!(catalog.can_delegate("coder", "writer")); assert!(!catalog.can_delegate("coder", "coder")); assert_eq!(catalog.delegate_targets("coder").len(), 4); // Explicit list allows exactly the listed targets. assert!(catalog.can_delegate("writer", "reviewer")); assert!(!catalog.can_delegate("writer", "researcher")); assert_eq!(catalog.delegate_targets("writer"), ["reviewer"]); } #[test] fn catalog_accepts_any_ordinary_tool_but_rejects_runtime_injected() { // Ordinary tools (including side-effecting ones like file_write) are // now accepted purely by the definition file. let root = tempfile::tempdir().unwrap(); std::fs::create_dir(root.path().join("agents")).unwrap(); write_agent(root.path(), "researcher", &["file_write"], None); let tools = ToolRegistry::new(); tools.register(crate::tools::FileWriteTool::new()); let loader = SkillsLoader::new_for_testing( root.path().join("skills"), root.path().join("external-skills"), ); let profiles = HashMap::from([("research".to_string(), provider())]); AgentCatalog::load( &config(), root.path(), &profiles, &HashMap::new(), &HashMap::new(), root.path(), &tools, &loader, 1, ) .unwrap(); // Runtime-injected tools (e.g. delegate) must not be declared in a // definition's `tools` list; get_skill remains the one exception. let root2 = tempfile::tempdir().unwrap(); std::fs::create_dir(root2.path().join("agents")).unwrap(); write_agent(root2.path(), "researcher", &["get_skill"], None); let tools2 = ToolRegistry::new(); tools2.register(GetSkillTool::new(Arc::new( crate::skills::SkillsLoader::new_for_testing( root2.path().join("skills"), root2.path().join("external-skills"), ), ))); let loader2 = SkillsLoader::new_for_testing( root2.path().join("skills"), root2.path().join("external-skills"), ); let profiles2 = HashMap::from([("research".to_string(), provider())]); AgentCatalog::load( &config(), root2.path(), &profiles2, &HashMap::new(), &HashMap::new(), root2.path(), &tools2, &loader2, 1, ) .unwrap(); } #[cfg(unix)] #[test] fn catalog_rejects_definition_symlink_escape() { use std::os::unix::fs::symlink; let root = tempfile::tempdir().unwrap(); let outside = tempfile::tempdir().unwrap(); std::fs::create_dir(root.path().join("agents")).unwrap(); let outside_file = outside.path().join("researcher.md"); std::fs::write( &outside_file, "---\nid: researcher\ndescription: test\nllm_profile: research\n---\n# Role\n", ) .unwrap(); symlink(&outside_file, root.path().join("agents/researcher.md")).unwrap(); let tools = ToolRegistry::new(); let loader = SkillsLoader::new_for_testing( root.path().join("skills"), root.path().join("external-skills"), ); let profiles = HashMap::from([("research".to_string(), provider())]); assert!( AgentCatalog::load( &config(), root.path(), &profiles, &HashMap::new(), &HashMap::new(), root.path(), &tools, &loader, 1 ) .is_err() ); } }