use crate::core::Prompt;
use llm::{LlmModel, ReasoningEffort, ToolDefinition};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct AgentSpec {
pub name: String,
pub description: String,
pub model: String,
pub reasoning_effort: Option<ReasoningEffort>,
pub prompts: Vec<Prompt>,
pub mcp_config_paths: Vec<PathBuf>,
pub exposure: AgentSpecExposure,
pub tools: ToolFilter,
}
impl AgentSpec {
pub fn default_spec(model: &LlmModel, reasoning_effort: Option<ReasoningEffort>, prompts: Vec<Prompt>) -> Self {
Self {
name: "__default__".to_string(),
description: "Default agent".to_string(),
model: model.to_string(),
reasoning_effort,
prompts,
mcp_config_paths: Vec::new(),
exposure: AgentSpecExposure::none(),
tools: ToolFilter::default(),
}
}
pub fn resolve_mcp_config(&mut self, inherited: &[PathBuf], cwd: &Path) {
if !self.mcp_config_paths.is_empty() {
return;
}
if !inherited.is_empty() {
self.mcp_config_paths = inherited.to_vec();
return;
}
let cwd_mcp = cwd.join("mcp.json");
if cwd_mcp.is_file() {
self.mcp_config_paths = vec![cwd_mcp];
}
}
}
#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)]
pub struct ToolFilter {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allow: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub deny: Vec<String>,
}
impl ToolFilter {
pub fn is_empty(&self) -> bool {
self.allow.is_empty() && self.deny.is_empty()
}
pub fn apply(&self, tools: Vec<ToolDefinition>) -> Vec<ToolDefinition> {
tools.into_iter().filter(|t| self.is_allowed(&t.name)).collect()
}
pub fn is_allowed(&self, tool_name: &str) -> bool {
let allowed = self.allow.is_empty() || self.allow.iter().any(|p| matches_pattern(p, tool_name));
allowed && !self.deny.iter().any(|p| matches_pattern(p, tool_name))
}
}
fn matches_pattern(pattern: &str, name: &str) -> bool {
if let Some(prefix) = pattern.strip_suffix('*') { name.starts_with(prefix) } else { pattern == name }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct AgentSpecExposure {
pub user_invocable: bool,
pub agent_invocable: bool,
}
impl AgentSpecExposure {
pub fn none() -> Self {
Self { user_invocable: false, agent_invocable: false }
}
pub fn user_only() -> Self {
Self { user_invocable: true, agent_invocable: false }
}
pub fn agent_only() -> Self {
Self { user_invocable: false, agent_invocable: true }
}
pub fn both() -> Self {
Self { user_invocable: true, agent_invocable: true }
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn make_spec() -> AgentSpec {
AgentSpec {
name: "test".to_string(),
description: "Test agent".to_string(),
model: "anthropic:claude-sonnet-4-5".to_string(),
reasoning_effort: None,
prompts: vec![],
mcp_config_paths: Vec::new(),
exposure: AgentSpecExposure::both(),
tools: ToolFilter::default(),
}
}
#[test]
fn default_spec_has_expected_fields() {
let model: LlmModel = "anthropic:claude-sonnet-4-5".parse().unwrap();
let prompts = vec![Prompt::from_globs(vec!["BASE.md".to_string()], PathBuf::from("/tmp"))];
let spec = AgentSpec::default_spec(&model, None, prompts.clone());
assert_eq!(spec.name, "__default__");
assert_eq!(spec.description, "Default agent");
assert_eq!(spec.model, model.to_string());
assert!(spec.reasoning_effort.is_none());
assert_eq!(spec.prompts.len(), 1);
assert!(spec.mcp_config_paths.is_empty());
assert_eq!(spec.exposure, AgentSpecExposure::none());
}
#[test]
fn resolve_mcp_prefers_agent_local_paths() {
let dir = tempfile::tempdir().unwrap();
let agent_path = dir.path().join("agent-mcp.json");
let inherited_path = dir.path().join("inherited-mcp.json");
fs::write(&agent_path, "{}").unwrap();
fs::write(&inherited_path, "{}").unwrap();
let mut spec = make_spec();
spec.mcp_config_paths = vec![agent_path.clone()];
spec.resolve_mcp_config(&[inherited_path], dir.path());
assert_eq!(spec.mcp_config_paths, vec![agent_path]);
}
#[test]
fn resolve_mcp_falls_back_to_inherited() {
let dir = tempfile::tempdir().unwrap();
let inherited_path = dir.path().join("inherited-mcp.json");
fs::write(&inherited_path, "{}").unwrap();
fs::write(dir.path().join("mcp.json"), "{}").unwrap();
let mut spec = make_spec();
spec.resolve_mcp_config(std::slice::from_ref(&inherited_path), dir.path());
assert_eq!(spec.mcp_config_paths, vec![inherited_path]);
}
#[test]
fn resolve_mcp_falls_back_to_cwd() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("mcp.json"), "{}").unwrap();
let mut spec = make_spec();
spec.resolve_mcp_config(&[], dir.path());
assert_eq!(spec.mcp_config_paths, vec![dir.path().join("mcp.json")]);
}
#[test]
fn resolve_mcp_yields_empty_when_nothing_found() {
let dir = tempfile::tempdir().unwrap();
let mut spec = make_spec();
spec.resolve_mcp_config(&[], dir.path());
assert!(spec.mcp_config_paths.is_empty());
}
fn make_tool(name: &str) -> ToolDefinition {
ToolDefinition { name: name.to_string(), description: String::new(), parameters: String::new(), server: None }
}
#[test]
fn empty_filter_allows_all_tools() {
let filter = ToolFilter::default();
let tools = vec![make_tool("bash"), make_tool("read_file")];
let result = filter.apply(tools);
assert_eq!(result.len(), 2);
}
#[test]
fn allow_keeps_only_matching_tools() {
let filter = ToolFilter { allow: vec!["read_file".to_string(), "grep".to_string()], deny: vec![] };
let tools = vec![make_tool("bash"), make_tool("read_file"), make_tool("grep")];
let result = filter.apply(tools);
let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
assert_eq!(names, vec!["read_file", "grep"]);
}
#[test]
fn deny_removes_matching_tools() {
let filter = ToolFilter { allow: vec![], deny: vec!["bash".to_string()] };
let tools = vec![make_tool("bash"), make_tool("read_file")];
let result = filter.apply(tools);
let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
assert_eq!(names, vec!["read_file"]);
}
#[test]
fn wildcard_matching() {
let filter = ToolFilter { allow: vec!["coding__*".to_string()], deny: vec![] };
let tools = vec![make_tool("coding__grep"), make_tool("coding__read_file"), make_tool("plugins__bash")];
let result = filter.apply(tools);
let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
assert_eq!(names, vec!["coding__grep", "coding__read_file"]);
}
#[test]
fn combined_allow_and_deny() {
let filter = ToolFilter { allow: vec!["coding__*".to_string()], deny: vec!["coding__write_file".to_string()] };
let tools = vec![
make_tool("coding__grep"),
make_tool("coding__write_file"),
make_tool("coding__read_file"),
make_tool("plugins__bash"),
];
let result = filter.apply(tools);
let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
assert_eq!(names, vec!["coding__grep", "coding__read_file"]);
}
#[test]
fn is_allowed_exact_match() {
let filter = ToolFilter { allow: vec!["bash".to_string()], deny: vec![] };
assert!(filter.is_allowed("bash"));
assert!(!filter.is_allowed("bash_extended"));
}
#[test]
fn matches_pattern_exact_and_wildcard() {
assert!(matches_pattern("foo", "foo"));
assert!(!matches_pattern("foo", "foobar"));
assert!(matches_pattern("foo*", "foobar"));
assert!(matches_pattern("foo*", "foo"));
assert!(!matches_pattern("bar*", "foo"));
}
}