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.
689 lines
25 KiB
Rust
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()
|
|
);
|
|
}
|
|
}
|