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, ProviderConnectionOverrides, ReasoningEffort, ToolDefinition};
8use mcp_utils::client::McpConfig;
9use std::path::PathBuf;
10
11#[derive(Debug, Clone)]
12pub enum McpConfigSource {
13    File { path: PathBuf, proxy: bool },
14    Json(String),
15    Inline(McpConfig),
16}
17
18impl McpConfigSource {
19    pub fn file(path: PathBuf, proxy: bool) -> Self {
20        Self::File { path, proxy }
21    }
22
23    pub fn direct(path: PathBuf) -> Self {
24        Self::file(path, false)
25    }
26
27    pub fn proxied(path: PathBuf) -> Self {
28        Self::file(path, true)
29    }
30}
31
32/// A resolved agent specification ready for runtime use.
33///
34/// This type is produced by validating and resolving authored agent configuration.
35/// All validation happens before constructing these runtime types.
36#[derive(Debug, Clone)]
37pub struct AgentSpec {
38    /// The canonical lookup key for this agent.
39    pub name: String,
40    /// Human-readable description of this agent's purpose.
41    pub description: String,
42    /// The validated model spec to use for this agent.
43    ///
44    /// This is stored as a canonical string so authored settings can represent
45    /// both single models (`provider:model`) and alloy specs
46    /// (`provider1:model1,provider2:model2`).
47    pub model: String,
48    /// Optional reasoning effort level for models that support it.
49    pub reasoning_effort: Option<ReasoningEffort>,
50    /// Effective context window in tokens for this agent.
51    pub context_window: Option<u32>,
52    /// The prompt stack for this agent.
53    pub prompts: Vec<Prompt>,
54    /// Provider connection overrides keyed by model provider name.
55    pub provider_connections: ProviderConnectionOverrides,
56    /// Resolved MCP config sources for this agent, applied in order.
57    ///
58    /// Direct server name collisions use last-source-wins semantics. Proxy-enabled
59    /// file sources are merged into a single runtime tool proxy.
60    pub mcp_config_sources: Vec<McpConfigSource>,
61    /// How this agent can be invoked.
62    pub exposure: AgentSpecExposure,
63    /// Tool filter for restricting which MCP tools this agent can use.
64    pub tools: ToolFilter,
65}
66
67impl AgentSpec {
68    /// Create a default (no-mode) agent spec with the provided prompts.
69    pub fn default_spec(model: &LlmModel, reasoning_effort: Option<ReasoningEffort>, prompts: Vec<Prompt>) -> Self {
70        Self {
71            name: "__default__".to_string(),
72            description: "Default agent".to_string(),
73            model: model.to_string(),
74            reasoning_effort,
75            context_window: None,
76            prompts,
77            provider_connections: ProviderConnectionOverrides::default(),
78            mcp_config_sources: Vec::new(),
79            exposure: AgentSpecExposure::none(),
80            tools: ToolFilter::default(),
81        }
82    }
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize, schemars::JsonSchema)]
86#[serde(untagged)]
87pub enum ToolMatcher {
88    Name(String),
89    Annotations(ToolAnnotationMatcher),
90}
91
92impl ToolMatcher {
93    pub fn name(pattern: impl Into<String>) -> Self {
94        Self::Name(pattern.into())
95    }
96
97    pub fn read_only() -> Self {
98        Self::Annotations(ToolAnnotationMatcher { read_only: Some(true), ..ToolAnnotationMatcher::default() })
99    }
100
101    pub fn annotations(matcher: ToolAnnotationMatcher) -> Self {
102        Self::Annotations(matcher)
103    }
104
105    pub fn matches(&self, tool: &ToolDefinition) -> bool {
106        match self {
107            Self::Name(pattern) => matches_pattern(pattern, &tool.name),
108            Self::Annotations(matcher) => matcher.matches(tool),
109        }
110    }
111}
112
113#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize, schemars::JsonSchema)]
114#[serde(rename_all = "camelCase", deny_unknown_fields)]
115pub struct ToolAnnotationMatcher {
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub read_only: Option<bool>,
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub destructive: Option<bool>,
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub idempotent: Option<bool>,
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub open_world: Option<bool>,
124}
125
126impl ToolAnnotationMatcher {
127    pub fn matches(&self, tool: &ToolDefinition) -> bool {
128        let Some(annotations) = tool.annotations.as_ref() else {
129            return false;
130        };
131        let pairs = [
132            (self.read_only, annotations.read_only_hint),
133            (self.destructive, annotations.destructive_hint),
134            (self.idempotent, annotations.idempotent_hint),
135            (self.open_world, annotations.open_world_hint),
136        ];
137        if pairs.iter().all(|(field, _)| field.is_none()) {
138            return false;
139        }
140        pairs.iter().all(|(field, hint)| field.is_none_or(|value| *hint == Some(value)))
141    }
142}
143
144/// Filter for restricting which tools an agent can use.
145///
146/// Supports `allow` (allowlist) and `deny` (blocklist) with name patterns and MCP annotation matchers.
147/// If both are set, allow is applied first, then deny removes from the result.
148/// An empty filter (the default) allows all tools.
149#[doc = ""]
150#[doc = include_str!("docs/tool_filter.md")]
151#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize, schemars::JsonSchema)]
152#[serde(rename_all = "camelCase", deny_unknown_fields)]
153pub struct ToolFilter {
154    /// If non-empty, only tools matching these patterns or annotations are allowed.
155    #[serde(default, skip_serializing_if = "Vec::is_empty")]
156    pub allow: Vec<ToolMatcher>,
157    /// Tools matching these patterns or annotations are removed.
158    #[serde(default, skip_serializing_if = "Vec::is_empty")]
159    pub deny: Vec<ToolMatcher>,
160}
161
162impl ToolFilter {
163    pub fn is_empty(&self) -> bool {
164        self.allow.is_empty() && self.deny.is_empty()
165    }
166
167    /// Apply this filter to a list of tool definitions.
168    pub fn apply(&self, tools: Vec<ToolDefinition>) -> Vec<ToolDefinition> {
169        tools.into_iter().filter(|tool| self.is_tool_allowed(tool)).collect()
170    }
171
172    pub fn is_tool_allowed(&self, tool: &ToolDefinition) -> bool {
173        let allowed = self.allow.is_empty() || self.allow.iter().any(|matcher| matcher.matches(tool));
174        let denied = self.deny.iter().any(|matcher| matcher.matches(tool));
175        allowed && !denied
176    }
177}
178
179/// Match a pattern against a name, supporting a trailing `*` wildcard.
180fn matches_pattern(pattern: &str, name: &str) -> bool {
181    if let Some(prefix) = pattern.strip_suffix('*') { name.starts_with(prefix) } else { pattern == name }
182}
183
184/// Defines how an agent can be invoked.
185#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
186pub struct AgentSpecExposure {
187    /// Whether this agent can be invoked by users (e.g., as an ACP mode).
188    pub user_invocable: bool,
189    /// Whether this agent can be invoked by other agents (e.g., as a sub-agent).
190    pub agent_invocable: bool,
191}
192
193impl AgentSpecExposure {
194    /// Create an exposure that is neither user nor agent invocable.
195    ///
196    /// Used internally for synthesized default specs (e.g., no-mode sessions).
197    /// Not intended for authored agent definitions — all authored agents must
198    /// have at least one invocation surface.
199    pub fn none() -> Self {
200        Self { user_invocable: false, agent_invocable: false }
201    }
202
203    /// Create an exposure that is only user invocable.
204    pub fn user_only() -> Self {
205        Self { user_invocable: true, agent_invocable: false }
206    }
207
208    /// Create an exposure that is only agent invocable.
209    pub fn agent_only() -> Self {
210        Self { user_invocable: false, agent_invocable: true }
211    }
212
213    /// Create an exposure that is both user and agent invocable.
214    pub fn both() -> Self {
215        Self { user_invocable: true, agent_invocable: true }
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use llm::ToolAnnotations;
223
224    #[test]
225    fn default_spec_has_expected_fields() {
226        let model: LlmModel = "anthropic:claude-sonnet-4-5".parse().unwrap();
227        let prompts = vec![Prompt::file(PathBuf::from("/tmp/BASE.md"), PathBuf::from("/tmp"))];
228        let spec = AgentSpec::default_spec(&model, None, prompts.clone());
229
230        assert_eq!(spec.name, "__default__");
231        assert_eq!(spec.description, "Default agent");
232        assert_eq!(spec.model, model.to_string());
233        assert!(spec.reasoning_effort.is_none());
234        assert_eq!(spec.prompts.len(), 1);
235        assert!(spec.mcp_config_sources.is_empty());
236        assert_eq!(spec.exposure, AgentSpecExposure::none());
237    }
238
239    fn make_tool(name: &str) -> ToolDefinition {
240        ToolDefinition::new(name, "", "")
241    }
242
243    fn make_annotated_tool(name: &str, annotations: ToolAnnotations) -> ToolDefinition {
244        ToolDefinition::new(name, "", "").with_annotations(annotations)
245    }
246
247    #[test]
248    fn empty_filter_allows_all_tools() {
249        let filter = ToolFilter::default();
250        let tools = vec![make_tool("bash"), make_tool("read_file")];
251        let result = filter.apply(tools);
252        assert_eq!(result.len(), 2);
253    }
254
255    #[test]
256    fn allow_keeps_only_matching_tools() {
257        let filter =
258            ToolFilter { allow: vec![ToolMatcher::name("read_file"), ToolMatcher::name("grep")], deny: vec![] };
259        let tools = vec![make_tool("bash"), make_tool("read_file"), make_tool("grep")];
260        let result = filter.apply(tools);
261        let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
262        assert_eq!(names, vec!["read_file", "grep"]);
263    }
264
265    #[test]
266    fn deny_removes_matching_tools() {
267        let filter = ToolFilter { allow: vec![], deny: vec![ToolMatcher::name("bash")] };
268        let tools = vec![make_tool("bash"), make_tool("read_file")];
269        let result = filter.apply(tools);
270        let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
271        assert_eq!(names, vec!["read_file"]);
272    }
273
274    #[test]
275    fn wildcard_matching() {
276        let filter = ToolFilter { allow: vec![ToolMatcher::name("coding__*")], deny: vec![] };
277        let tools = vec![make_tool("coding__grep"), make_tool("coding__read_file"), make_tool("plugins__bash")];
278        let result = filter.apply(tools);
279        let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
280        assert_eq!(names, vec!["coding__grep", "coding__read_file"]);
281    }
282
283    #[test]
284    fn combined_allow_and_deny() {
285        let filter = ToolFilter {
286            allow: vec![ToolMatcher::name("coding__*")],
287            deny: vec![ToolMatcher::name("coding__write_file")],
288        };
289        let tools = vec![
290            make_tool("coding__grep"),
291            make_tool("coding__write_file"),
292            make_tool("coding__read_file"),
293            make_tool("plugins__bash"),
294        ];
295        let result = filter.apply(tools);
296        let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
297        assert_eq!(names, vec!["coding__grep", "coding__read_file"]);
298    }
299
300    #[test]
301    fn annotation_allow_matches_present_values() {
302        let filter = ToolFilter { allow: vec![ToolMatcher::read_only()], deny: vec![] };
303        let tools = vec![
304            make_tool("unknown"),
305            make_annotated_tool("read", ToolAnnotations { read_only_hint: Some(true), ..ToolAnnotations::default() }),
306            make_annotated_tool("write", ToolAnnotations { read_only_hint: Some(false), ..ToolAnnotations::default() }),
307        ];
308        let names: Vec<_> = filter.apply(tools).into_iter().map(|tool| tool.name).collect();
309        assert_eq!(names, vec!["read"]);
310    }
311
312    #[test]
313    fn deny_annotation_removes_destructive_tools() {
314        let filter = ToolFilter {
315            allow: vec![],
316            deny: vec![ToolMatcher::annotations(ToolAnnotationMatcher {
317                destructive: Some(true),
318                ..ToolAnnotationMatcher::default()
319            })],
320        };
321        let tools = vec![
322            make_tool("unknown"),
323            make_annotated_tool(
324                "safe_update",
325                ToolAnnotations {
326                    read_only_hint: Some(false),
327                    destructive_hint: Some(false),
328                    ..ToolAnnotations::default()
329                },
330            ),
331        ];
332        let names: Vec<_> = filter.apply(tools).into_iter().map(|tool| tool.name).collect();
333        assert_eq!(names, vec!["unknown", "safe_update"]);
334    }
335
336    #[test]
337    fn annotation_matchers_do_not_match_missing_fields() {
338        let filter = ToolFilter {
339            allow: vec![],
340            deny: vec![
341                ToolMatcher::annotations(ToolAnnotationMatcher {
342                    destructive: Some(true),
343                    ..ToolAnnotationMatcher::default()
344                }),
345                ToolMatcher::annotations(ToolAnnotationMatcher {
346                    open_world: Some(true),
347                    ..ToolAnnotationMatcher::default()
348                }),
349                ToolMatcher::annotations(ToolAnnotationMatcher {
350                    idempotent: Some(false),
351                    ..ToolAnnotationMatcher::default()
352                }),
353                ToolMatcher::annotations(ToolAnnotationMatcher {
354                    read_only: Some(false),
355                    ..ToolAnnotationMatcher::default()
356                }),
357            ],
358        };
359        let tools = vec![make_tool("unknown")];
360        let names: Vec<_> = filter.apply(tools).into_iter().map(|tool| tool.name).collect();
361        assert_eq!(names, vec!["unknown"]);
362    }
363
364    #[test]
365    fn annotation_matchers_do_not_infer_fields_from_read_only_hint() {
366        let filter = ToolFilter {
367            allow: vec![ToolMatcher::annotations(ToolAnnotationMatcher {
368                destructive: Some(false),
369                ..ToolAnnotationMatcher::default()
370            })],
371            deny: vec![],
372        };
373        let tools = vec![make_annotated_tool("read", ToolAnnotations::read_only())];
374        assert!(filter.apply(tools).is_empty());
375    }
376
377    #[test]
378    fn deny_wins_over_allow() {
379        let filter =
380            ToolFilter { allow: vec![ToolMatcher::read_only()], deny: vec![ToolMatcher::name("coding__read_file")] };
381        let tools = vec![make_annotated_tool(
382            "coding__read_file",
383            ToolAnnotations { read_only_hint: Some(true), ..ToolAnnotations::default() },
384        )];
385        assert!(filter.apply(tools).is_empty());
386    }
387
388    #[test]
389    fn mixed_allow_entries_are_ored() {
390        let filter = ToolFilter { allow: vec![ToolMatcher::read_only(), ToolMatcher::name("plan__*")], deny: vec![] };
391        let tools = vec![
392            make_annotated_tool(
393                "coding__grep",
394                ToolAnnotations { read_only_hint: Some(true), ..ToolAnnotations::default() },
395            ),
396            make_tool("plan__write_plan"),
397            make_tool("coding__bash"),
398        ];
399        let names: Vec<_> = filter.apply(tools).into_iter().map(|tool| tool.name).collect();
400        assert_eq!(names, vec!["coding__grep", "plan__write_plan"]);
401    }
402
403    #[test]
404    fn empty_annotation_matcher_matches_nothing() {
405        let filter =
406            ToolFilter { allow: vec![ToolMatcher::annotations(ToolAnnotationMatcher::default())], deny: vec![] };
407        let tools = vec![make_annotated_tool(
408            "coding__grep",
409            ToolAnnotations { read_only_hint: Some(true), ..ToolAnnotations::default() },
410        )];
411        assert!(filter.apply(tools).is_empty());
412    }
413
414    #[test]
415    fn exact_name_match_is_not_a_prefix_match() {
416        let filter = ToolFilter { allow: vec![ToolMatcher::name("bash")], deny: vec![] };
417        let names: Vec<_> =
418            filter.apply(vec![make_tool("bash"), make_tool("bash_extended")]).into_iter().map(|t| t.name).collect();
419        assert_eq!(names, vec!["bash"]);
420    }
421
422    #[test]
423    fn matches_pattern_exact_and_wildcard() {
424        assert!(matches_pattern("foo", "foo"));
425        assert!(!matches_pattern("foo", "foobar"));
426        assert!(matches_pattern("foo*", "foobar"));
427        assert!(matches_pattern("foo*", "foo"));
428        assert!(!matches_pattern("bar*", "foo"));
429    }
430}