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};
8use mcp_utils::client::{McpConfig, ToolFilter};
9use std::path::PathBuf;
10
11#[derive(Debug, Clone)]
12pub enum McpConfigSource {
13    File { path: PathBuf, defer_tools: bool },
14    Json(String),
15    Inline(McpConfig),
16}
17
18impl McpConfigSource {
19    pub fn file(path: PathBuf, defer_tools: bool) -> Self {
20        Self::File { path, defer_tools }
21    }
22
23    pub fn model_visible(path: PathBuf) -> Self {
24        Self::file(path, false)
25    }
26
27    pub fn deferred(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    /// Model-visible server name collisions use last-source-wins semantics. File sources
61    /// can defer all of their tools for progressive discovery.
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/// Defines how an agent can be invoked.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
91pub struct AgentSpecExposure {
92    /// Whether this agent can be invoked by users (e.g., as an ACP mode).
93    pub user_invocable: bool,
94    /// Whether this agent can be invoked by other agents (e.g., as a sub-agent).
95    pub agent_invocable: bool,
96}
97
98impl AgentSpecExposure {
99    /// Create an exposure that is neither user nor agent invocable.
100    ///
101    /// Used internally for synthesized default specs (e.g., no-mode sessions).
102    /// Not intended for authored agent definitions — all authored agents must
103    /// have at least one invocation surface.
104    pub fn none() -> Self {
105        Self { user_invocable: false, agent_invocable: false }
106    }
107
108    /// Create an exposure that is only user invocable.
109    pub fn user_only() -> Self {
110        Self { user_invocable: true, agent_invocable: false }
111    }
112
113    /// Create an exposure that is only agent invocable.
114    pub fn agent_only() -> Self {
115        Self { user_invocable: false, agent_invocable: true }
116    }
117
118    /// Create an exposure that is both user and agent invocable.
119    pub fn both() -> Self {
120        Self { user_invocable: true, agent_invocable: true }
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use llm::{ToolAnnotations, ToolDefinition};
128    use mcp_utils::client::{ToolAnnotationMatcher, ToolMatcher};
129
130    #[test]
131    fn default_spec_has_expected_fields() {
132        let model: LlmModel = "anthropic:claude-sonnet-4-5".parse().unwrap();
133        let prompts = vec![Prompt::file(PathBuf::from("/tmp/BASE.md"), PathBuf::from("/tmp"))];
134        let spec = AgentSpec::bare(&model, None, prompts.clone());
135
136        assert_eq!(spec.name, "__default__");
137        assert_eq!(spec.description, "Default agent");
138        assert_eq!(spec.model, model.to_string());
139        assert!(spec.reasoning_effort.is_none());
140        assert_eq!(spec.prompts.len(), 1);
141        assert!(spec.mcp_config_sources.is_empty());
142        assert_eq!(spec.exposure, AgentSpecExposure::none());
143    }
144
145    fn make_tool(name: &str) -> ToolDefinition {
146        ToolDefinition::new(name, "", serde_json::json!({}))
147    }
148
149    fn make_annotated_tool(name: &str, annotations: ToolAnnotations) -> ToolDefinition {
150        ToolDefinition::new(name, "", serde_json::json!({})).with_annotations(annotations)
151    }
152
153    #[test]
154    fn empty_filter_allows_all_tools() {
155        let filter = ToolFilter::default();
156        let tools = vec![make_tool("bash"), make_tool("read_file")];
157        let result = filter.apply(tools);
158        assert_eq!(result.len(), 2);
159    }
160
161    #[test]
162    fn allow_keeps_only_matching_tools() {
163        let filter =
164            ToolFilter { allow: vec![ToolMatcher::name("read_file"), ToolMatcher::name("grep")], deny: vec![] };
165        let tools = vec![make_tool("bash"), make_tool("read_file"), make_tool("grep")];
166        let result = filter.apply(tools);
167        let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
168        assert_eq!(names, vec!["read_file", "grep"]);
169    }
170
171    #[test]
172    fn deny_removes_matching_tools() {
173        let filter = ToolFilter { allow: vec![], deny: vec![ToolMatcher::name("bash")] };
174        let tools = vec![make_tool("bash"), make_tool("read_file")];
175        let result = filter.apply(tools);
176        let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
177        assert_eq!(names, vec!["read_file"]);
178    }
179
180    #[test]
181    fn wildcard_matching() {
182        let filter = ToolFilter { allow: vec![ToolMatcher::name("coding__*")], deny: vec![] };
183        let tools = vec![make_tool("coding__grep"), make_tool("coding__read_file"), make_tool("plugins__bash")];
184        let result = filter.apply(tools);
185        let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
186        assert_eq!(names, vec!["coding__grep", "coding__read_file"]);
187    }
188
189    #[test]
190    fn combined_allow_and_deny() {
191        let filter = ToolFilter {
192            allow: vec![ToolMatcher::name("coding__*")],
193            deny: vec![ToolMatcher::name("coding__write_file")],
194        };
195        let tools = vec![
196            make_tool("coding__grep"),
197            make_tool("coding__write_file"),
198            make_tool("coding__read_file"),
199            make_tool("plugins__bash"),
200        ];
201        let result = filter.apply(tools);
202        let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
203        assert_eq!(names, vec!["coding__grep", "coding__read_file"]);
204    }
205
206    #[test]
207    fn annotation_allow_matches_present_values() {
208        let filter = ToolFilter { allow: vec![ToolMatcher::read_only()], deny: vec![] };
209        let tools = vec![
210            make_tool("unknown"),
211            make_annotated_tool("read", ToolAnnotations { read_only_hint: Some(true), ..ToolAnnotations::default() }),
212            make_annotated_tool("write", ToolAnnotations { read_only_hint: Some(false), ..ToolAnnotations::default() }),
213        ];
214        let names: Vec<_> = filter.apply(tools).into_iter().map(|tool| tool.name).collect();
215        assert_eq!(names, vec!["read"]);
216    }
217
218    #[test]
219    fn deny_annotation_removes_destructive_tools() {
220        let filter = ToolFilter {
221            allow: vec![],
222            deny: vec![ToolMatcher::annotations(ToolAnnotationMatcher {
223                destructive: Some(true),
224                ..ToolAnnotationMatcher::default()
225            })],
226        };
227        let tools = vec![
228            make_tool("unknown"),
229            make_annotated_tool(
230                "safe_update",
231                ToolAnnotations {
232                    read_only_hint: Some(false),
233                    destructive_hint: Some(false),
234                    ..ToolAnnotations::default()
235                },
236            ),
237        ];
238        let names: Vec<_> = filter.apply(tools).into_iter().map(|tool| tool.name).collect();
239        assert_eq!(names, vec!["unknown", "safe_update"]);
240    }
241
242    #[test]
243    fn annotation_matchers_do_not_match_missing_fields() {
244        let filter = ToolFilter {
245            allow: vec![],
246            deny: vec![
247                ToolMatcher::annotations(ToolAnnotationMatcher {
248                    destructive: Some(true),
249                    ..ToolAnnotationMatcher::default()
250                }),
251                ToolMatcher::annotations(ToolAnnotationMatcher {
252                    open_world: Some(true),
253                    ..ToolAnnotationMatcher::default()
254                }),
255                ToolMatcher::annotations(ToolAnnotationMatcher {
256                    idempotent: Some(false),
257                    ..ToolAnnotationMatcher::default()
258                }),
259                ToolMatcher::annotations(ToolAnnotationMatcher {
260                    read_only: Some(false),
261                    ..ToolAnnotationMatcher::default()
262                }),
263            ],
264        };
265        let tools = vec![make_tool("unknown")];
266        let names: Vec<_> = filter.apply(tools).into_iter().map(|tool| tool.name).collect();
267        assert_eq!(names, vec!["unknown"]);
268    }
269
270    #[test]
271    fn annotation_matchers_do_not_infer_fields_from_read_only_hint() {
272        let filter = ToolFilter {
273            allow: vec![ToolMatcher::annotations(ToolAnnotationMatcher {
274                destructive: Some(false),
275                ..ToolAnnotationMatcher::default()
276            })],
277            deny: vec![],
278        };
279        let tools = vec![make_annotated_tool("read", ToolAnnotations::read_only())];
280        assert!(filter.apply(tools).is_empty());
281    }
282
283    #[test]
284    fn deny_wins_over_allow() {
285        let filter =
286            ToolFilter { allow: vec![ToolMatcher::read_only()], deny: vec![ToolMatcher::name("coding__read_file")] };
287        let tools = vec![make_annotated_tool(
288            "coding__read_file",
289            ToolAnnotations { read_only_hint: Some(true), ..ToolAnnotations::default() },
290        )];
291        assert!(filter.apply(tools).is_empty());
292    }
293
294    #[test]
295    fn mixed_allow_entries_are_ored() {
296        let filter = ToolFilter { allow: vec![ToolMatcher::read_only(), ToolMatcher::name("plan__*")], deny: vec![] };
297        let tools = vec![
298            make_annotated_tool(
299                "coding__grep",
300                ToolAnnotations { read_only_hint: Some(true), ..ToolAnnotations::default() },
301            ),
302            make_tool("plan__write_plan"),
303            make_tool("coding__bash"),
304        ];
305        let names: Vec<_> = filter.apply(tools).into_iter().map(|tool| tool.name).collect();
306        assert_eq!(names, vec!["coding__grep", "plan__write_plan"]);
307    }
308
309    #[test]
310    fn empty_annotation_matcher_matches_nothing() {
311        let filter =
312            ToolFilter { allow: vec![ToolMatcher::annotations(ToolAnnotationMatcher::default())], deny: vec![] };
313        let tools = vec![make_annotated_tool(
314            "coding__grep",
315            ToolAnnotations { read_only_hint: Some(true), ..ToolAnnotations::default() },
316        )];
317        assert!(filter.apply(tools).is_empty());
318    }
319
320    #[test]
321    fn exact_name_match_is_not_a_prefix_match() {
322        let filter = ToolFilter { allow: vec![ToolMatcher::name("bash")], deny: vec![] };
323        let names: Vec<_> =
324            filter.apply(vec![make_tool("bash"), make_tool("bash_extended")]).into_iter().map(|t| t.name).collect();
325        assert_eq!(names, vec!["bash"]);
326    }
327
328    #[test]
329    fn tool_matcher_uses_exact_and_trailing_wildcard_names() {
330        let exact = ToolMatcher::name("foo");
331        let wildcard = ToolMatcher::name("foo*");
332        assert!(exact.matches(&make_tool("foo")));
333        assert!(!exact.matches(&make_tool("foobar")));
334        assert!(wildcard.matches(&make_tool("foobar")));
335        assert!(wildcard.matches(&make_tool("foo")));
336        assert!(!wildcard.matches(&make_tool("bar")));
337    }
338}