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>,
36 pub mcp_config_paths: Vec<PathBuf>,
42 pub exposure: AgentSpecExposure,
44 pub tools: ToolFilter,
46}
47
48impl AgentSpec {
49 pub fn default_spec(model: &LlmModel, reasoning_effort: Option<ReasoningEffort>, prompts: Vec<Prompt>) -> Self {
51 Self {
52 name: "__default__".to_string(),
53 description: "Default agent".to_string(),
54 model: model.to_string(),
55 reasoning_effort,
56 prompts,
57 mcp_config_paths: Vec::new(),
58 exposure: AgentSpecExposure::none(),
59 tools: ToolFilter::default(),
60 }
61 }
62
63 pub fn resolve_mcp_config(&mut self, inherited: &[PathBuf], cwd: &Path) {
68 if !self.mcp_config_paths.is_empty() {
69 return;
70 }
71 if !inherited.is_empty() {
72 self.mcp_config_paths = inherited.to_vec();
73 return;
74 }
75 let cwd_mcp = cwd.join("mcp.json");
76 if cwd_mcp.is_file() {
77 self.mcp_config_paths = vec![cwd_mcp];
78 }
79 }
80}
81
82#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)]
88pub struct ToolFilter {
89 #[serde(default, skip_serializing_if = "Vec::is_empty")]
91 pub allow: Vec<String>,
92 #[serde(default, skip_serializing_if = "Vec::is_empty")]
94 pub deny: Vec<String>,
95}
96
97impl ToolFilter {
98 pub fn is_empty(&self) -> bool {
99 self.allow.is_empty() && self.deny.is_empty()
100 }
101
102 pub fn apply(&self, tools: Vec<ToolDefinition>) -> Vec<ToolDefinition> {
104 tools.into_iter().filter(|t| self.is_allowed(&t.name)).collect()
105 }
106
107 pub fn is_allowed(&self, tool_name: &str) -> bool {
109 let allowed = self.allow.is_empty() || self.allow.iter().any(|p| matches_pattern(p, tool_name));
110 allowed && !self.deny.iter().any(|p| matches_pattern(p, tool_name))
111 }
112}
113
114fn matches_pattern(pattern: &str, name: &str) -> bool {
116 if let Some(prefix) = pattern.strip_suffix('*') { name.starts_with(prefix) } else { pattern == name }
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
121pub struct AgentSpecExposure {
122 pub user_invocable: bool,
124 pub agent_invocable: bool,
126}
127
128impl AgentSpecExposure {
129 pub fn none() -> Self {
135 Self { user_invocable: false, agent_invocable: false }
136 }
137
138 pub fn user_only() -> Self {
140 Self { user_invocable: true, agent_invocable: false }
141 }
142
143 pub fn agent_only() -> Self {
145 Self { user_invocable: false, agent_invocable: true }
146 }
147
148 pub fn both() -> Self {
150 Self { user_invocable: true, agent_invocable: true }
151 }
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157 use std::fs;
158
159 fn make_spec() -> AgentSpec {
160 AgentSpec {
161 name: "test".to_string(),
162 description: "Test agent".to_string(),
163 model: "anthropic:claude-sonnet-4-5".to_string(),
164 reasoning_effort: None,
165 prompts: vec![],
166 mcp_config_paths: Vec::new(),
167 exposure: AgentSpecExposure::both(),
168 tools: ToolFilter::default(),
169 }
170 }
171
172 #[test]
173 fn default_spec_has_expected_fields() {
174 let model: LlmModel = "anthropic:claude-sonnet-4-5".parse().unwrap();
175 let prompts = vec![Prompt::from_globs(vec!["BASE.md".to_string()], PathBuf::from("/tmp"))];
176 let spec = AgentSpec::default_spec(&model, None, prompts.clone());
177
178 assert_eq!(spec.name, "__default__");
179 assert_eq!(spec.description, "Default agent");
180 assert_eq!(spec.model, model.to_string());
181 assert!(spec.reasoning_effort.is_none());
182 assert_eq!(spec.prompts.len(), 1);
183 assert!(spec.mcp_config_paths.is_empty());
184 assert_eq!(spec.exposure, AgentSpecExposure::none());
185 }
186
187 #[test]
188 fn resolve_mcp_prefers_agent_local_paths() {
189 let dir = tempfile::tempdir().unwrap();
190 let agent_path = dir.path().join("agent-mcp.json");
191 let inherited_path = dir.path().join("inherited-mcp.json");
192 fs::write(&agent_path, "{}").unwrap();
193 fs::write(&inherited_path, "{}").unwrap();
194
195 let mut spec = make_spec();
196 spec.mcp_config_paths = vec![agent_path.clone()];
197
198 spec.resolve_mcp_config(&[inherited_path], dir.path());
199 assert_eq!(spec.mcp_config_paths, vec![agent_path]);
200 }
201
202 #[test]
203 fn resolve_mcp_falls_back_to_inherited() {
204 let dir = tempfile::tempdir().unwrap();
205 let inherited_path = dir.path().join("inherited-mcp.json");
206 fs::write(&inherited_path, "{}").unwrap();
207 fs::write(dir.path().join("mcp.json"), "{}").unwrap();
208
209 let mut spec = make_spec();
210 spec.resolve_mcp_config(std::slice::from_ref(&inherited_path), dir.path());
211 assert_eq!(spec.mcp_config_paths, vec![inherited_path]);
212 }
213
214 #[test]
215 fn resolve_mcp_falls_back_to_cwd() {
216 let dir = tempfile::tempdir().unwrap();
217 fs::write(dir.path().join("mcp.json"), "{}").unwrap();
218
219 let mut spec = make_spec();
220 spec.resolve_mcp_config(&[], dir.path());
221 assert_eq!(spec.mcp_config_paths, vec![dir.path().join("mcp.json")]);
222 }
223
224 #[test]
225 fn resolve_mcp_yields_empty_when_nothing_found() {
226 let dir = tempfile::tempdir().unwrap();
227 let mut spec = make_spec();
228 spec.resolve_mcp_config(&[], dir.path());
229 assert!(spec.mcp_config_paths.is_empty());
230 }
231
232 fn make_tool(name: &str) -> ToolDefinition {
233 ToolDefinition { name: name.to_string(), description: String::new(), parameters: String::new(), server: None }
234 }
235
236 #[test]
237 fn empty_filter_allows_all_tools() {
238 let filter = ToolFilter::default();
239 let tools = vec![make_tool("bash"), make_tool("read_file")];
240 let result = filter.apply(tools);
241 assert_eq!(result.len(), 2);
242 }
243
244 #[test]
245 fn allow_keeps_only_matching_tools() {
246 let filter = ToolFilter { allow: vec!["read_file".to_string(), "grep".to_string()], deny: vec![] };
247 let tools = vec![make_tool("bash"), make_tool("read_file"), make_tool("grep")];
248 let result = filter.apply(tools);
249 let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
250 assert_eq!(names, vec!["read_file", "grep"]);
251 }
252
253 #[test]
254 fn deny_removes_matching_tools() {
255 let filter = ToolFilter { allow: vec![], deny: vec!["bash".to_string()] };
256 let tools = vec![make_tool("bash"), make_tool("read_file")];
257 let result = filter.apply(tools);
258 let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
259 assert_eq!(names, vec!["read_file"]);
260 }
261
262 #[test]
263 fn wildcard_matching() {
264 let filter = ToolFilter { allow: vec!["coding__*".to_string()], deny: vec![] };
265 let tools = vec![make_tool("coding__grep"), make_tool("coding__read_file"), make_tool("plugins__bash")];
266 let result = filter.apply(tools);
267 let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
268 assert_eq!(names, vec!["coding__grep", "coding__read_file"]);
269 }
270
271 #[test]
272 fn combined_allow_and_deny() {
273 let filter = ToolFilter { allow: vec!["coding__*".to_string()], deny: vec!["coding__write_file".to_string()] };
274 let tools = vec![
275 make_tool("coding__grep"),
276 make_tool("coding__write_file"),
277 make_tool("coding__read_file"),
278 make_tool("plugins__bash"),
279 ];
280 let result = filter.apply(tools);
281 let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
282 assert_eq!(names, vec!["coding__grep", "coding__read_file"]);
283 }
284
285 #[test]
286 fn is_allowed_exact_match() {
287 let filter = ToolFilter { allow: vec!["bash".to_string()], deny: vec![] };
288 assert!(filter.is_allowed("bash"));
289 assert!(!filter.is_allowed("bash_extended"));
290 }
291
292 #[test]
293 fn matches_pattern_exact_and_wildcard() {
294 assert!(matches_pattern("foo", "foo"));
295 assert!(!matches_pattern("foo", "foobar"));
296 assert!(matches_pattern("foo*", "foobar"));
297 assert!(matches_pattern("foo*", "foo"));
298 assert!(!matches_pattern("bar*", "foo"));
299 }
300}