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