1use crate::core::Prompt;
7use llm::{LlmModel, ReasoningEffort, ToolDefinition};
8use std::path::{Path, PathBuf};
9
10#[derive(Debug, Clone)]
15pub struct AgentSpec {
16 pub name: String,
18 pub description: String,
20 pub model: String,
26 pub reasoning_effort: Option<ReasoningEffort>,
28 pub prompts: Vec<Prompt>,
35 pub mcp_config_path: Option<PathBuf>,
40 pub exposure: AgentSpecExposure,
42 pub tools: ToolFilter,
44}
45
46impl AgentSpec {
47 pub fn default_spec(
49 model: &LlmModel,
50 reasoning_effort: Option<ReasoningEffort>,
51 prompts: Vec<Prompt>,
52 ) -> Self {
53 Self {
54 name: "__default__".to_string(),
55 description: "Default agent".to_string(),
56 model: model.to_string(),
57 reasoning_effort,
58 prompts,
59 mcp_config_path: None,
60 exposure: AgentSpecExposure::none(),
61 tools: ToolFilter::default(),
62 }
63 }
64
65 pub fn resolve_mcp_config(&mut self, inherited: Option<&Path>, cwd: &Path) {
70 if self.mcp_config_path.is_some() {
71 return;
72 }
73 if let Some(path) = inherited {
74 self.mcp_config_path = Some(path.to_path_buf());
75 return;
76 }
77 let cwd_mcp = cwd.join("mcp.json");
78 if cwd_mcp.is_file() {
79 self.mcp_config_path = Some(cwd_mcp);
80 }
81 }
82}
83
84#[derive(Debug, Clone, Default, serde::Deserialize)]
90pub struct ToolFilter {
91 #[serde(default)]
93 pub allow: Vec<String>,
94 #[serde(default)]
96 pub deny: Vec<String>,
97}
98
99impl ToolFilter {
100 pub fn apply(&self, tools: Vec<ToolDefinition>) -> Vec<ToolDefinition> {
102 tools
103 .into_iter()
104 .filter(|t| self.is_allowed(&t.name))
105 .collect()
106 }
107
108 pub fn is_allowed(&self, tool_name: &str) -> bool {
110 let allowed =
111 self.allow.is_empty() || self.allow.iter().any(|p| matches_pattern(p, tool_name));
112 allowed && !self.deny.iter().any(|p| matches_pattern(p, tool_name))
113 }
114}
115
116fn matches_pattern(pattern: &str, name: &str) -> bool {
118 if let Some(prefix) = pattern.strip_suffix('*') {
119 name.starts_with(prefix)
120 } else {
121 pattern == name
122 }
123}
124
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
127pub struct AgentSpecExposure {
128 pub user_invocable: bool,
130 pub agent_invocable: bool,
132}
133
134impl AgentSpecExposure {
135 pub fn none() -> Self {
141 Self {
142 user_invocable: false,
143 agent_invocable: false,
144 }
145 }
146
147 pub fn user_only() -> Self {
149 Self {
150 user_invocable: true,
151 agent_invocable: false,
152 }
153 }
154
155 pub fn agent_only() -> Self {
157 Self {
158 user_invocable: false,
159 agent_invocable: true,
160 }
161 }
162
163 pub fn both() -> Self {
165 Self {
166 user_invocable: true,
167 agent_invocable: true,
168 }
169 }
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175 use std::fs;
176
177 fn make_spec() -> AgentSpec {
178 AgentSpec {
179 name: "test".to_string(),
180 description: "Test agent".to_string(),
181 model: "anthropic:claude-sonnet-4-5".to_string(),
182 reasoning_effort: None,
183 prompts: vec![],
184 mcp_config_path: None,
185 exposure: AgentSpecExposure::both(),
186 tools: ToolFilter::default(),
187 }
188 }
189
190 #[test]
191 fn default_spec_has_expected_fields() {
192 let model: LlmModel = "anthropic:claude-sonnet-4-5".parse().unwrap();
193 let prompts = vec![Prompt::from_globs(
194 vec!["BASE.md".to_string()],
195 PathBuf::from("/tmp"),
196 )];
197 let spec = AgentSpec::default_spec(&model, None, prompts.clone());
198
199 assert_eq!(spec.name, "__default__");
200 assert_eq!(spec.description, "Default agent");
201 assert_eq!(spec.model, model.to_string());
202 assert!(spec.reasoning_effort.is_none());
203 assert_eq!(spec.prompts.len(), 1);
204 assert!(spec.mcp_config_path.is_none());
205 assert_eq!(spec.exposure, AgentSpecExposure::none());
206 }
207
208 #[test]
209 fn resolve_mcp_prefers_agent_local_path() {
210 let dir = tempfile::tempdir().unwrap();
211 let agent_path = dir.path().join("agent-mcp.json");
212 let inherited_path = dir.path().join("inherited-mcp.json");
213 fs::write(&agent_path, "{}").unwrap();
214 fs::write(&inherited_path, "{}").unwrap();
215
216 let mut spec = make_spec();
217 spec.mcp_config_path = Some(agent_path.clone());
218
219 spec.resolve_mcp_config(Some(&inherited_path), dir.path());
220 assert_eq!(spec.mcp_config_path, Some(agent_path));
221 }
222
223 #[test]
224 fn resolve_mcp_falls_back_to_inherited() {
225 let dir = tempfile::tempdir().unwrap();
226 let inherited_path = dir.path().join("inherited-mcp.json");
227 fs::write(&inherited_path, "{}").unwrap();
228 fs::write(dir.path().join("mcp.json"), "{}").unwrap();
229
230 let mut spec = make_spec();
231 spec.resolve_mcp_config(Some(&inherited_path), dir.path());
232 assert_eq!(spec.mcp_config_path, Some(inherited_path));
233 }
234
235 #[test]
236 fn resolve_mcp_falls_back_to_cwd() {
237 let dir = tempfile::tempdir().unwrap();
238 fs::write(dir.path().join("mcp.json"), "{}").unwrap();
239
240 let mut spec = make_spec();
241 spec.resolve_mcp_config(None, dir.path());
242 assert_eq!(spec.mcp_config_path, Some(dir.path().join("mcp.json")));
243 }
244
245 #[test]
246 fn resolve_mcp_returns_none_when_nothing_found() {
247 let dir = tempfile::tempdir().unwrap();
248 let mut spec = make_spec();
249 spec.resolve_mcp_config(None, dir.path());
250 assert!(spec.mcp_config_path.is_none());
251 }
252
253 fn make_tool(name: &str) -> ToolDefinition {
254 ToolDefinition {
255 name: name.to_string(),
256 description: String::new(),
257 parameters: String::new(),
258 server: None,
259 }
260 }
261
262 #[test]
263 fn empty_filter_allows_all_tools() {
264 let filter = ToolFilter::default();
265 let tools = vec![make_tool("bash"), make_tool("read_file")];
266 let result = filter.apply(tools);
267 assert_eq!(result.len(), 2);
268 }
269
270 #[test]
271 fn allow_keeps_only_matching_tools() {
272 let filter = ToolFilter {
273 allow: vec!["read_file".to_string(), "grep".to_string()],
274 deny: vec![],
275 };
276 let tools = vec![make_tool("bash"), make_tool("read_file"), make_tool("grep")];
277 let result = filter.apply(tools);
278 let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
279 assert_eq!(names, vec!["read_file", "grep"]);
280 }
281
282 #[test]
283 fn deny_removes_matching_tools() {
284 let filter = ToolFilter {
285 allow: vec![],
286 deny: vec!["bash".to_string()],
287 };
288 let tools = vec![make_tool("bash"), make_tool("read_file")];
289 let result = filter.apply(tools);
290 let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
291 assert_eq!(names, vec!["read_file"]);
292 }
293
294 #[test]
295 fn wildcard_matching() {
296 let filter = ToolFilter {
297 allow: vec!["coding__*".to_string()],
298 deny: vec![],
299 };
300 let tools = vec![
301 make_tool("coding__grep"),
302 make_tool("coding__read_file"),
303 make_tool("plugins__bash"),
304 ];
305 let result = filter.apply(tools);
306 let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
307 assert_eq!(names, vec!["coding__grep", "coding__read_file"]);
308 }
309
310 #[test]
311 fn combined_allow_and_deny() {
312 let filter = ToolFilter {
313 allow: vec!["coding__*".to_string()],
314 deny: vec!["coding__write_file".to_string()],
315 };
316 let tools = vec![
317 make_tool("coding__grep"),
318 make_tool("coding__write_file"),
319 make_tool("coding__read_file"),
320 make_tool("plugins__bash"),
321 ];
322 let result = filter.apply(tools);
323 let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
324 assert_eq!(names, vec!["coding__grep", "coding__read_file"]);
325 }
326
327 #[test]
328 fn is_allowed_exact_match() {
329 let filter = ToolFilter {
330 allow: vec!["bash".to_string()],
331 deny: vec![],
332 };
333 assert!(filter.is_allowed("bash"));
334 assert!(!filter.is_allowed("bash_extended"));
335 }
336
337 #[test]
338 fn matches_pattern_exact_and_wildcard() {
339 assert!(matches_pattern("foo", "foo"));
340 assert!(!matches_pattern("foo", "foobar"));
341 assert!(matches_pattern("foo*", "foobar"));
342 assert!(matches_pattern("foo*", "foo"));
343 assert!(!matches_pattern("bar*", "foo"));
344 }
345}