Skip to main content

aether_core/
agent_spec.rs

1//! Agent specification types for authored agent definitions.
2//!
3//! `AgentSpec` is the canonical abstraction for authored agent definitions across the stack.
4//! It represents a resolved runtime type, not a raw settings DTO.
5
6use crate::core::Prompt;
7use llm::{LlmModel, ReasoningEffort, ToolDefinition};
8use std::path::{Path, PathBuf};
9
10/// A resolved agent specification ready for runtime use.
11///
12/// This type is produced by validating and resolving authored agent configuration.
13/// All validation happens before constructing these runtime types.
14#[derive(Debug, Clone)]
15pub struct AgentSpec {
16    /// The canonical lookup key for this agent.
17    pub name: String,
18    /// Human-readable description of this agent's purpose.
19    pub description: String,
20    /// The validated model spec to use for this agent.
21    ///
22    /// This is stored as a canonical string so authored settings can represent
23    /// both single models (`provider:model`) and alloy specs
24    /// (`provider1:model1,provider2:model2`).
25    pub model: String,
26    /// Optional reasoning effort level for models that support it.
27    pub reasoning_effort: Option<ReasoningEffort>,
28    /// The prompt stack for this agent.
29    ///
30    /// For authored `AgentSpec`s resolved from settings, this contains authored prompt
31    /// variants (e.g., `Prompt::PromptGlobs`). Prompt files may include
32    /// `` !`<shell command>` `` markers which are replaced by the trimmed stdout of
33    /// the command at prompt-load time. `Prompt::McpInstructions` is added separately
34    /// during agent construction.
35    pub prompts: Vec<Prompt>,
36    /// Resolved MCP config paths for this agent, applied in order.
37    ///
38    /// On server name collisions across files, the last entry in the list wins.
39    /// Before catalog resolution, this holds agent-local overrides (empty if none).
40    /// After catalog resolution, this holds the effective list (agent-local > inherited > cwd/mcp.json).
41    pub mcp_config_paths: Vec<PathBuf>,
42    /// How this agent can be invoked.
43    pub exposure: AgentSpecExposure,
44    /// Tool filter for restricting which MCP tools this agent can use.
45    pub tools: ToolFilter,
46}
47
48impl AgentSpec {
49    /// Create a default (no-mode) agent spec with inherited prompts.
50    pub fn default_spec(model: &LlmModel, reasoning_effort: Option<ReasoningEffort>, prompts: Vec<Prompt>) -> Self {
51        Self {
52            name: "__default__".to_string(),
53            description: "Default agent".to_string(),
54            model: model.to_string(),
55            reasoning_effort,
56            prompts,
57            mcp_config_paths: Vec::new(),
58            exposure: AgentSpecExposure::none(),
59            tools: ToolFilter::default(),
60        }
61    }
62
63    /// Resolve effective MCP config paths in place using precedence:
64    /// 1. Agent's own `mcp_config_paths` (kept as-is if non-empty)
65    /// 2. `inherited` paths from settings (if non-empty)
66    /// 3. `cwd/mcp.json` (becomes a single-element list if it exists)
67    pub fn resolve_mcp_config(&mut self, inherited: &[PathBuf], cwd: &Path) {
68        if !self.mcp_config_paths.is_empty() {
69            return;
70        }
71        if !inherited.is_empty() {
72            self.mcp_config_paths = inherited.to_vec();
73            return;
74        }
75        let cwd_mcp = cwd.join("mcp.json");
76        if cwd_mcp.is_file() {
77            self.mcp_config_paths = vec![cwd_mcp];
78        }
79    }
80}
81
82/// Filter for restricting which tools an agent can use.
83///
84/// Supports `allow` (allowlist) and `deny` (blocklist) with trailing `*` wildcards.
85/// If both are set, allow is applied first, then deny removes from the result.
86/// An empty filter (the default) allows all tools.
87#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)]
88pub struct ToolFilter {
89    /// If non-empty, only tools matching these patterns are allowed.
90    #[serde(default, skip_serializing_if = "Vec::is_empty")]
91    pub allow: Vec<String>,
92    /// Tools matching these patterns are removed.
93    #[serde(default, skip_serializing_if = "Vec::is_empty")]
94    pub deny: Vec<String>,
95}
96
97impl ToolFilter {
98    pub fn is_empty(&self) -> bool {
99        self.allow.is_empty() && self.deny.is_empty()
100    }
101
102    /// Apply this filter to a list of tool definitions.
103    pub fn apply(&self, tools: Vec<ToolDefinition>) -> Vec<ToolDefinition> {
104        tools.into_iter().filter(|t| self.is_allowed(&t.name)).collect()
105    }
106
107    /// Check whether a tool name passes this filter.
108    pub fn is_allowed(&self, tool_name: &str) -> bool {
109        let allowed = self.allow.is_empty() || self.allow.iter().any(|p| matches_pattern(p, tool_name));
110        allowed && !self.deny.iter().any(|p| matches_pattern(p, tool_name))
111    }
112}
113
114/// Match a pattern against a name, supporting a trailing `*` wildcard.
115fn matches_pattern(pattern: &str, name: &str) -> bool {
116    if let Some(prefix) = pattern.strip_suffix('*') { name.starts_with(prefix) } else { pattern == name }
117}
118
119/// Defines how an agent can be invoked.
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
121pub struct AgentSpecExposure {
122    /// Whether this agent can be invoked by users (e.g., as an ACP mode).
123    pub user_invocable: bool,
124    /// Whether this agent can be invoked by other agents (e.g., as a sub-agent).
125    pub agent_invocable: bool,
126}
127
128impl AgentSpecExposure {
129    /// Create an exposure that is neither user nor agent invocable.
130    ///
131    /// Used internally for synthesized default specs (e.g., no-mode sessions).
132    /// Not intended for authored agent definitions — all authored agents must
133    /// have at least one invocation surface.
134    pub fn none() -> Self {
135        Self { user_invocable: false, agent_invocable: false }
136    }
137
138    /// Create an exposure that is only user invocable.
139    pub fn user_only() -> Self {
140        Self { user_invocable: true, agent_invocable: false }
141    }
142
143    /// Create an exposure that is only agent invocable.
144    pub fn agent_only() -> Self {
145        Self { user_invocable: false, agent_invocable: true }
146    }
147
148    /// Create an exposure that is both user and agent invocable.
149    pub fn both() -> Self {
150        Self { user_invocable: true, agent_invocable: true }
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use std::fs;
158
159    fn make_spec() -> AgentSpec {
160        AgentSpec {
161            name: "test".to_string(),
162            description: "Test agent".to_string(),
163            model: "anthropic:claude-sonnet-4-5".to_string(),
164            reasoning_effort: None,
165            prompts: vec![],
166            mcp_config_paths: Vec::new(),
167            exposure: AgentSpecExposure::both(),
168            tools: ToolFilter::default(),
169        }
170    }
171
172    #[test]
173    fn default_spec_has_expected_fields() {
174        let model: LlmModel = "anthropic:claude-sonnet-4-5".parse().unwrap();
175        let prompts = vec![Prompt::from_globs(vec!["BASE.md".to_string()], PathBuf::from("/tmp"))];
176        let spec = AgentSpec::default_spec(&model, None, prompts.clone());
177
178        assert_eq!(spec.name, "__default__");
179        assert_eq!(spec.description, "Default agent");
180        assert_eq!(spec.model, model.to_string());
181        assert!(spec.reasoning_effort.is_none());
182        assert_eq!(spec.prompts.len(), 1);
183        assert!(spec.mcp_config_paths.is_empty());
184        assert_eq!(spec.exposure, AgentSpecExposure::none());
185    }
186
187    #[test]
188    fn resolve_mcp_prefers_agent_local_paths() {
189        let dir = tempfile::tempdir().unwrap();
190        let agent_path = dir.path().join("agent-mcp.json");
191        let inherited_path = dir.path().join("inherited-mcp.json");
192        fs::write(&agent_path, "{}").unwrap();
193        fs::write(&inherited_path, "{}").unwrap();
194
195        let mut spec = make_spec();
196        spec.mcp_config_paths = vec![agent_path.clone()];
197
198        spec.resolve_mcp_config(&[inherited_path], dir.path());
199        assert_eq!(spec.mcp_config_paths, vec![agent_path]);
200    }
201
202    #[test]
203    fn resolve_mcp_falls_back_to_inherited() {
204        let dir = tempfile::tempdir().unwrap();
205        let inherited_path = dir.path().join("inherited-mcp.json");
206        fs::write(&inherited_path, "{}").unwrap();
207        fs::write(dir.path().join("mcp.json"), "{}").unwrap();
208
209        let mut spec = make_spec();
210        spec.resolve_mcp_config(std::slice::from_ref(&inherited_path), dir.path());
211        assert_eq!(spec.mcp_config_paths, vec![inherited_path]);
212    }
213
214    #[test]
215    fn resolve_mcp_falls_back_to_cwd() {
216        let dir = tempfile::tempdir().unwrap();
217        fs::write(dir.path().join("mcp.json"), "{}").unwrap();
218
219        let mut spec = make_spec();
220        spec.resolve_mcp_config(&[], dir.path());
221        assert_eq!(spec.mcp_config_paths, vec![dir.path().join("mcp.json")]);
222    }
223
224    #[test]
225    fn resolve_mcp_yields_empty_when_nothing_found() {
226        let dir = tempfile::tempdir().unwrap();
227        let mut spec = make_spec();
228        spec.resolve_mcp_config(&[], dir.path());
229        assert!(spec.mcp_config_paths.is_empty());
230    }
231
232    fn make_tool(name: &str) -> ToolDefinition {
233        ToolDefinition { name: name.to_string(), description: String::new(), parameters: String::new(), server: None }
234    }
235
236    #[test]
237    fn empty_filter_allows_all_tools() {
238        let filter = ToolFilter::default();
239        let tools = vec![make_tool("bash"), make_tool("read_file")];
240        let result = filter.apply(tools);
241        assert_eq!(result.len(), 2);
242    }
243
244    #[test]
245    fn allow_keeps_only_matching_tools() {
246        let filter = ToolFilter { allow: vec!["read_file".to_string(), "grep".to_string()], deny: vec![] };
247        let tools = vec![make_tool("bash"), make_tool("read_file"), make_tool("grep")];
248        let result = filter.apply(tools);
249        let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
250        assert_eq!(names, vec!["read_file", "grep"]);
251    }
252
253    #[test]
254    fn deny_removes_matching_tools() {
255        let filter = ToolFilter { allow: vec![], deny: vec!["bash".to_string()] };
256        let tools = vec![make_tool("bash"), make_tool("read_file")];
257        let result = filter.apply(tools);
258        let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
259        assert_eq!(names, vec!["read_file"]);
260    }
261
262    #[test]
263    fn wildcard_matching() {
264        let filter = ToolFilter { allow: vec!["coding__*".to_string()], deny: vec![] };
265        let tools = vec![make_tool("coding__grep"), make_tool("coding__read_file"), make_tool("plugins__bash")];
266        let result = filter.apply(tools);
267        let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
268        assert_eq!(names, vec!["coding__grep", "coding__read_file"]);
269    }
270
271    #[test]
272    fn combined_allow_and_deny() {
273        let filter = ToolFilter { allow: vec!["coding__*".to_string()], deny: vec!["coding__write_file".to_string()] };
274        let tools = vec![
275            make_tool("coding__grep"),
276            make_tool("coding__write_file"),
277            make_tool("coding__read_file"),
278            make_tool("plugins__bash"),
279        ];
280        let result = filter.apply(tools);
281        let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
282        assert_eq!(names, vec!["coding__grep", "coding__read_file"]);
283    }
284
285    #[test]
286    fn is_allowed_exact_match() {
287        let filter = ToolFilter { allow: vec!["bash".to_string()], deny: vec![] };
288        assert!(filter.is_allowed("bash"));
289        assert!(!filter.is_allowed("bash_extended"));
290    }
291
292    #[test]
293    fn matches_pattern_exact_and_wildcard() {
294        assert!(matches_pattern("foo", "foo"));
295        assert!(!matches_pattern("foo", "foobar"));
296        assert!(matches_pattern("foo*", "foobar"));
297        assert!(matches_pattern("foo*", "foo"));
298        assert!(!matches_pattern("bar*", "foo"));
299    }
300}