PicoBot/src/agent/catalog.rs
xiaoxixi b558a0a99b feat: make sub-agent orchestration always-on, consolidate design docs
Remove the agent_orchestration enabled feature switch and root_delegates config; delegation edges now derive from the catalog's delegate_targets. Replace the four orchestration design/review docs with a single SUB_AGENT_DESIGN.md. Bump version to 1.13.0.
2026-08-13 18:07:32 +08:00

689 lines
25 KiB
Rust

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<String, Arc<AgentDefinition>>,
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<String, LLMProviderConfig>,
providers: &HashMap<String, ProviderConfig>,
models: &HashMap<String, ModelConfig>,
workspace_dir: &Path,
tools: &ToolRegistry,
skills_loader: &SkillsLoader,
runtime_generation: u64,
) -> Result<Self, AgentCatalogError> {
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<String> = 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<Arc<AgentDefinition>> {
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<String> {
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<Arc<AgentDefinition>> {
self.definitions.values().cloned().collect()
}
}
fn definition_paths(directory: &Path) -> Result<Vec<PathBuf>, 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<String>,
provider: Option<String>,
model: Option<String>,
token_limit: Option<usize>,
max_tool_iterations: Option<usize>,
enabled: bool,
}
fn read_provider_spec(path: &Path) -> Result<ProviderSpec, AgentCatalogError> {
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<String>,
provider: Option<String>,
model: Option<String>,
token_limit: Option<usize>,
max_tool_iterations: Option<usize>,
#[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<String, LLMProviderConfig>,
providers: &HashMap<String, ProviderConfig>,
models: &HashMap<String, ModelConfig>,
workspace_dir: &Path,
) -> Result<LLMProviderConfig, AgentCatalogError> {
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::<Vec<_>>()
.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::<Vec<_>>()
.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()
);
}
}