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 `$SYSTEM_ENV`
32    /// which is expanded to system environment info during resolution.
33    /// `Prompt::McpInstructions` is added separately during agent construction.
34    pub prompts: Vec<Prompt>,
35    /// Resolved MCP config path for this agent.
36    ///
37    /// Before catalog resolution, this holds the agent-local override.
38    /// After catalog resolution, this holds the effective path (agent-local > inherited > cwd).
39    pub mcp_config_path: Option<PathBuf>,
40    /// How this agent can be invoked.
41    pub exposure: AgentSpecExposure,
42    /// Tool filter for restricting which MCP tools this agent can use.
43    pub tools: ToolFilter,
44}
45
46impl AgentSpec {
47    /// Create a default (no-mode) agent spec with inherited prompts.
48    pub fn default_spec(
49        model: &LlmModel,
50        reasoning_effort: Option<ReasoningEffort>,
51        prompts: Vec<Prompt>,
52    ) -> Self {
53        Self {
54            name: "__default__".to_string(),
55            description: "Default agent".to_string(),
56            model: model.to_string(),
57            reasoning_effort,
58            prompts,
59            mcp_config_path: None,
60            exposure: AgentSpecExposure::none(),
61            tools: ToolFilter::default(),
62        }
63    }
64
65    /// Resolve effective MCP config path in place using precedence:
66    /// 1. Agent's own `mcp_config_path` (kept as-is)
67    /// 2. `inherited_mcp_config_path` (from settings)
68    /// 3. `cwd/mcp.json`
69    pub fn resolve_mcp_config(&mut self, inherited: Option<&Path>, cwd: &Path) {
70        if self.mcp_config_path.is_some() {
71            return;
72        }
73        if let Some(path) = inherited {
74            self.mcp_config_path = Some(path.to_path_buf());
75            return;
76        }
77        let cwd_mcp = cwd.join("mcp.json");
78        if cwd_mcp.is_file() {
79            self.mcp_config_path = Some(cwd_mcp);
80        }
81    }
82}
83
84/// Filter for restricting which tools an agent can use.
85///
86/// Supports `allow` (allowlist) and `deny` (blocklist) with trailing `*` wildcards.
87/// If both are set, allow is applied first, then deny removes from the result.
88/// An empty filter (the default) allows all tools.
89#[derive(Debug, Clone, Default, serde::Deserialize)]
90pub struct ToolFilter {
91    /// If non-empty, only tools matching these patterns are allowed.
92    #[serde(default)]
93    pub allow: Vec<String>,
94    /// Tools matching these patterns are removed.
95    #[serde(default)]
96    pub deny: Vec<String>,
97}
98
99impl ToolFilter {
100    /// Apply this filter to a list of tool definitions.
101    pub fn apply(&self, tools: Vec<ToolDefinition>) -> Vec<ToolDefinition> {
102        tools
103            .into_iter()
104            .filter(|t| self.is_allowed(&t.name))
105            .collect()
106    }
107
108    /// Check whether a tool name passes this filter.
109    pub fn is_allowed(&self, tool_name: &str) -> bool {
110        let allowed =
111            self.allow.is_empty() || self.allow.iter().any(|p| matches_pattern(p, tool_name));
112        allowed && !self.deny.iter().any(|p| matches_pattern(p, tool_name))
113    }
114}
115
116/// Match a pattern against a name, supporting a trailing `*` wildcard.
117fn matches_pattern(pattern: &str, name: &str) -> bool {
118    if let Some(prefix) = pattern.strip_suffix('*') {
119        name.starts_with(prefix)
120    } else {
121        pattern == name
122    }
123}
124
125/// Defines how an agent can be invoked.
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
127pub struct AgentSpecExposure {
128    /// Whether this agent can be invoked by users (e.g., as an ACP mode).
129    pub user_invocable: bool,
130    /// Whether this agent can be invoked by other agents (e.g., as a sub-agent).
131    pub agent_invocable: bool,
132}
133
134impl AgentSpecExposure {
135    /// Create an exposure that is neither user nor agent invocable.
136    ///
137    /// Used internally for synthesized default specs (e.g., no-mode sessions).
138    /// Not intended for authored agent definitions — all authored agents must
139    /// have at least one invocation surface.
140    pub fn none() -> Self {
141        Self {
142            user_invocable: false,
143            agent_invocable: false,
144        }
145    }
146
147    /// Create an exposure that is only user invocable.
148    pub fn user_only() -> Self {
149        Self {
150            user_invocable: true,
151            agent_invocable: false,
152        }
153    }
154
155    /// Create an exposure that is only agent invocable.
156    pub fn agent_only() -> Self {
157        Self {
158            user_invocable: false,
159            agent_invocable: true,
160        }
161    }
162
163    /// Create an exposure that is both user and agent invocable.
164    pub fn both() -> Self {
165        Self {
166            user_invocable: true,
167            agent_invocable: true,
168        }
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use std::fs;
176
177    fn make_spec() -> AgentSpec {
178        AgentSpec {
179            name: "test".to_string(),
180            description: "Test agent".to_string(),
181            model: "anthropic:claude-sonnet-4-5".to_string(),
182            reasoning_effort: None,
183            prompts: vec![],
184            mcp_config_path: None,
185            exposure: AgentSpecExposure::both(),
186            tools: ToolFilter::default(),
187        }
188    }
189
190    #[test]
191    fn default_spec_has_expected_fields() {
192        let model: LlmModel = "anthropic:claude-sonnet-4-5".parse().unwrap();
193        let prompts = vec![Prompt::from_globs(
194            vec!["BASE.md".to_string()],
195            PathBuf::from("/tmp"),
196        )];
197        let spec = AgentSpec::default_spec(&model, None, prompts.clone());
198
199        assert_eq!(spec.name, "__default__");
200        assert_eq!(spec.description, "Default agent");
201        assert_eq!(spec.model, model.to_string());
202        assert!(spec.reasoning_effort.is_none());
203        assert_eq!(spec.prompts.len(), 1);
204        assert!(spec.mcp_config_path.is_none());
205        assert_eq!(spec.exposure, AgentSpecExposure::none());
206    }
207
208    #[test]
209    fn resolve_mcp_prefers_agent_local_path() {
210        let dir = tempfile::tempdir().unwrap();
211        let agent_path = dir.path().join("agent-mcp.json");
212        let inherited_path = dir.path().join("inherited-mcp.json");
213        fs::write(&agent_path, "{}").unwrap();
214        fs::write(&inherited_path, "{}").unwrap();
215
216        let mut spec = make_spec();
217        spec.mcp_config_path = Some(agent_path.clone());
218
219        spec.resolve_mcp_config(Some(&inherited_path), dir.path());
220        assert_eq!(spec.mcp_config_path, Some(agent_path));
221    }
222
223    #[test]
224    fn resolve_mcp_falls_back_to_inherited() {
225        let dir = tempfile::tempdir().unwrap();
226        let inherited_path = dir.path().join("inherited-mcp.json");
227        fs::write(&inherited_path, "{}").unwrap();
228        fs::write(dir.path().join("mcp.json"), "{}").unwrap();
229
230        let mut spec = make_spec();
231        spec.resolve_mcp_config(Some(&inherited_path), dir.path());
232        assert_eq!(spec.mcp_config_path, Some(inherited_path));
233    }
234
235    #[test]
236    fn resolve_mcp_falls_back_to_cwd() {
237        let dir = tempfile::tempdir().unwrap();
238        fs::write(dir.path().join("mcp.json"), "{}").unwrap();
239
240        let mut spec = make_spec();
241        spec.resolve_mcp_config(None, dir.path());
242        assert_eq!(spec.mcp_config_path, Some(dir.path().join("mcp.json")));
243    }
244
245    #[test]
246    fn resolve_mcp_returns_none_when_nothing_found() {
247        let dir = tempfile::tempdir().unwrap();
248        let mut spec = make_spec();
249        spec.resolve_mcp_config(None, dir.path());
250        assert!(spec.mcp_config_path.is_none());
251    }
252
253    fn make_tool(name: &str) -> ToolDefinition {
254        ToolDefinition {
255            name: name.to_string(),
256            description: String::new(),
257            parameters: String::new(),
258            server: None,
259        }
260    }
261
262    #[test]
263    fn empty_filter_allows_all_tools() {
264        let filter = ToolFilter::default();
265        let tools = vec![make_tool("bash"), make_tool("read_file")];
266        let result = filter.apply(tools);
267        assert_eq!(result.len(), 2);
268    }
269
270    #[test]
271    fn allow_keeps_only_matching_tools() {
272        let filter = ToolFilter {
273            allow: vec!["read_file".to_string(), "grep".to_string()],
274            deny: vec![],
275        };
276        let tools = vec![make_tool("bash"), make_tool("read_file"), make_tool("grep")];
277        let result = filter.apply(tools);
278        let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
279        assert_eq!(names, vec!["read_file", "grep"]);
280    }
281
282    #[test]
283    fn deny_removes_matching_tools() {
284        let filter = ToolFilter {
285            allow: vec![],
286            deny: vec!["bash".to_string()],
287        };
288        let tools = vec![make_tool("bash"), make_tool("read_file")];
289        let result = filter.apply(tools);
290        let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
291        assert_eq!(names, vec!["read_file"]);
292    }
293
294    #[test]
295    fn wildcard_matching() {
296        let filter = ToolFilter {
297            allow: vec!["coding__*".to_string()],
298            deny: vec![],
299        };
300        let tools = vec![
301            make_tool("coding__grep"),
302            make_tool("coding__read_file"),
303            make_tool("plugins__bash"),
304        ];
305        let result = filter.apply(tools);
306        let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
307        assert_eq!(names, vec!["coding__grep", "coding__read_file"]);
308    }
309
310    #[test]
311    fn combined_allow_and_deny() {
312        let filter = ToolFilter {
313            allow: vec!["coding__*".to_string()],
314            deny: vec!["coding__write_file".to_string()],
315        };
316        let tools = vec![
317            make_tool("coding__grep"),
318            make_tool("coding__write_file"),
319            make_tool("coding__read_file"),
320            make_tool("plugins__bash"),
321        ];
322        let result = filter.apply(tools);
323        let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
324        assert_eq!(names, vec!["coding__grep", "coding__read_file"]);
325    }
326
327    #[test]
328    fn is_allowed_exact_match() {
329        let filter = ToolFilter {
330            allow: vec!["bash".to_string()],
331            deny: vec![],
332        };
333        assert!(filter.is_allowed("bash"));
334        assert!(!filter.is_allowed("bash_extended"));
335    }
336
337    #[test]
338    fn matches_pattern_exact_and_wildcard() {
339        assert!(matches_pattern("foo", "foo"));
340        assert!(!matches_pattern("foo", "foobar"));
341        assert!(matches_pattern("foo*", "foobar"));
342        assert!(matches_pattern("foo*", "foo"));
343        assert!(!matches_pattern("bar*", "foo"));
344    }
345}