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