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/// Filter for restricting which tools an agent can use.
86///
87/// Supports `allow` (allowlist) and `deny` (blocklist) with trailing `*` wildcards.
88/// If both are set, allow is applied first, then deny removes from the result.
89/// An empty filter (the default) allows all tools.
90#[doc = ""]
91#[doc = include_str!("docs/tool_filter.md")]
92#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize, schemars::JsonSchema)]
93pub struct ToolFilter {
94    /// If non-empty, only tools matching these patterns are allowed.
95    #[serde(default, skip_serializing_if = "Vec::is_empty")]
96    pub allow: Vec<String>,
97    /// Tools matching these patterns are removed.
98    #[serde(default, skip_serializing_if = "Vec::is_empty")]
99    pub deny: Vec<String>,
100}
101
102impl ToolFilter {
103    pub fn is_empty(&self) -> bool {
104        self.allow.is_empty() && self.deny.is_empty()
105    }
106
107    /// Apply this filter to a list of tool definitions.
108    pub fn apply(&self, tools: Vec<ToolDefinition>) -> Vec<ToolDefinition> {
109        tools.into_iter().filter(|t| self.is_allowed(&t.name)).collect()
110    }
111
112    /// Check whether a tool name passes this filter.
113    pub fn is_allowed(&self, tool_name: &str) -> bool {
114        let allowed = self.allow.is_empty() || self.allow.iter().any(|p| matches_pattern(p, tool_name));
115        allowed && !self.deny.iter().any(|p| matches_pattern(p, tool_name))
116    }
117}
118
119/// Match a pattern against a name, supporting a trailing `*` wildcard.
120fn matches_pattern(pattern: &str, name: &str) -> bool {
121    if let Some(prefix) = pattern.strip_suffix('*') { name.starts_with(prefix) } else { pattern == name }
122}
123
124/// Defines how an agent can be invoked.
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
126pub struct AgentSpecExposure {
127    /// Whether this agent can be invoked by users (e.g., as an ACP mode).
128    pub user_invocable: bool,
129    /// Whether this agent can be invoked by other agents (e.g., as a sub-agent).
130    pub agent_invocable: bool,
131}
132
133impl AgentSpecExposure {
134    /// Create an exposure that is neither user nor agent invocable.
135    ///
136    /// Used internally for synthesized default specs (e.g., no-mode sessions).
137    /// Not intended for authored agent definitions — all authored agents must
138    /// have at least one invocation surface.
139    pub fn none() -> Self {
140        Self { user_invocable: false, agent_invocable: false }
141    }
142
143    /// Create an exposure that is only user invocable.
144    pub fn user_only() -> Self {
145        Self { user_invocable: true, agent_invocable: false }
146    }
147
148    /// Create an exposure that is only agent invocable.
149    pub fn agent_only() -> Self {
150        Self { user_invocable: false, agent_invocable: true }
151    }
152
153    /// Create an exposure that is both user and agent invocable.
154    pub fn both() -> Self {
155        Self { user_invocable: true, agent_invocable: true }
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn default_spec_has_expected_fields() {
165        let model: LlmModel = "anthropic:claude-sonnet-4-5".parse().unwrap();
166        let prompts = vec![Prompt::file(PathBuf::from("/tmp/BASE.md"), PathBuf::from("/tmp"))];
167        let spec = AgentSpec::default_spec(&model, None, prompts.clone());
168
169        assert_eq!(spec.name, "__default__");
170        assert_eq!(spec.description, "Default agent");
171        assert_eq!(spec.model, model.to_string());
172        assert!(spec.reasoning_effort.is_none());
173        assert_eq!(spec.prompts.len(), 1);
174        assert!(spec.mcp_config_sources.is_empty());
175        assert_eq!(spec.exposure, AgentSpecExposure::none());
176    }
177
178    fn make_tool(name: &str) -> ToolDefinition {
179        ToolDefinition { name: name.to_string(), description: String::new(), parameters: String::new(), server: None }
180    }
181
182    #[test]
183    fn empty_filter_allows_all_tools() {
184        let filter = ToolFilter::default();
185        let tools = vec![make_tool("bash"), make_tool("read_file")];
186        let result = filter.apply(tools);
187        assert_eq!(result.len(), 2);
188    }
189
190    #[test]
191    fn allow_keeps_only_matching_tools() {
192        let filter = ToolFilter { allow: vec!["read_file".to_string(), "grep".to_string()], deny: vec![] };
193        let tools = vec![make_tool("bash"), make_tool("read_file"), make_tool("grep")];
194        let result = filter.apply(tools);
195        let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
196        assert_eq!(names, vec!["read_file", "grep"]);
197    }
198
199    #[test]
200    fn deny_removes_matching_tools() {
201        let filter = ToolFilter { allow: vec![], deny: vec!["bash".to_string()] };
202        let tools = vec![make_tool("bash"), make_tool("read_file")];
203        let result = filter.apply(tools);
204        let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
205        assert_eq!(names, vec!["read_file"]);
206    }
207
208    #[test]
209    fn wildcard_matching() {
210        let filter = ToolFilter { allow: vec!["coding__*".to_string()], deny: vec![] };
211        let tools = vec![make_tool("coding__grep"), make_tool("coding__read_file"), make_tool("plugins__bash")];
212        let result = filter.apply(tools);
213        let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
214        assert_eq!(names, vec!["coding__grep", "coding__read_file"]);
215    }
216
217    #[test]
218    fn combined_allow_and_deny() {
219        let filter =
220            { ToolFilter { allow: vec!["coding__*".to_string()], deny: vec!["coding__write_file".to_string()] } };
221        let tools = vec![
222            make_tool("coding__grep"),
223            make_tool("coding__write_file"),
224            make_tool("coding__read_file"),
225            make_tool("plugins__bash"),
226        ];
227        let result = filter.apply(tools);
228        let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
229        assert_eq!(names, vec!["coding__grep", "coding__read_file"]);
230    }
231
232    #[test]
233    fn is_allowed_exact_match() {
234        let filter = ToolFilter { allow: vec!["bash".to_string()], deny: vec![] };
235        assert!(filter.is_allowed("bash"));
236        assert!(!filter.is_allowed("bash_extended"));
237    }
238
239    #[test]
240    fn matches_pattern_exact_and_wildcard() {
241        assert!(matches_pattern("foo", "foo"));
242        assert!(!matches_pattern("foo", "foobar"));
243        assert!(matches_pattern("foo*", "foobar"));
244        assert!(matches_pattern("foo*", "foo"));
245        assert!(!matches_pattern("bar*", "foo"));
246    }
247}