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