1use crate::error::SettingsError;
2use crate::{AetherSettings, AgentConfig, McpSourceSpec};
3use aether_core::agent_spec::{AgentSpec, AgentSpecExposure, McpConfigSource};
4use aether_core::core::Prompt;
5use llm::LlmModel;
6use mcp_utils::client::RawMcpConfig;
7use std::collections::HashSet;
8use std::path::{Path, PathBuf};
9
10#[derive(Debug, Clone)]
14pub struct AgentCatalog {
15 project_root: PathBuf,
16 specs: Vec<AgentSpec>,
17 selected_agent: Option<String>,
18}
19
20impl AgentCatalog {
21 pub fn from_settings(project_root: &Path, settings: AetherSettings) -> Result<Self, SettingsError> {
22 validate_selected_agent(&settings)?;
23 let selected_agent =
24 settings.agent.as_deref().map(str::trim).filter(|name| !name.is_empty()).map(str::to_string);
25 let default_prompts = settings.prompts;
26 let default_mcps = settings.mcps;
27 let mut seen_names = HashSet::new();
28 let mut specs = Vec::with_capacity(settings.agents.len());
29 for (index, entry) in settings.agents.into_iter().enumerate() {
30 specs.push(resolve_agent_entry(
31 project_root,
32 entry,
33 &default_prompts,
34 &default_mcps,
35 index,
36 &mut seen_names,
37 )?);
38 }
39
40 Ok(Self::new(project_root.to_path_buf(), specs, selected_agent))
41 }
42
43 pub(crate) fn new(project_root: PathBuf, specs: Vec<AgentSpec>, selected_agent: Option<String>) -> Self {
44 Self { project_root, specs, selected_agent }
45 }
46
47 pub fn empty(project_root: PathBuf) -> Self {
49 Self::new(project_root, Vec::new(), None)
50 }
51
52 pub fn project_root(&self) -> &Path {
54 &self.project_root
55 }
56
57 pub fn all(&self) -> &[AgentSpec] {
59 &self.specs
60 }
61
62 pub fn selected_agent(&self) -> Option<&str> {
63 self.selected_agent.as_deref()
64 }
65
66 pub fn default_agent(&self) -> Option<&AgentSpec> {
67 self.selected_agent
68 .as_deref()
69 .and_then(|name| self.specs.iter().find(|spec| spec.name == name))
70 .or_else(|| self.user_invocable().next())
71 }
72
73 pub fn get(&self, name: &str) -> Result<&AgentSpec, SettingsError> {
75 self.specs
76 .iter()
77 .find(|spec| spec.name == name)
78 .ok_or_else(|| SettingsError::AgentNotFound { name: name.to_string() })
79 }
80
81 pub fn user_invocable(&self) -> impl Iterator<Item = &AgentSpec> {
83 self.specs.iter().filter(|s| s.exposure.user_invocable)
84 }
85
86 pub fn agent_invocable(&self) -> impl Iterator<Item = &AgentSpec> {
88 self.specs.iter().filter(|s| s.exposure.agent_invocable)
89 }
90
91 pub fn resolve(&self, name: &str) -> Result<AgentSpec, SettingsError> {
93 self.get(name).cloned()
94 }
95}
96
97fn validate_selected_agent(settings: &AetherSettings) -> Result<(), SettingsError> {
98 if settings.agents.is_empty() {
99 return Err(SettingsError::EmptyAgents);
100 }
101
102 if let Some(agent) = settings.agent.as_deref() {
103 let selector = agent.trim();
104 let Some(entry) = settings.agents.iter().find(|entry| entry.name.trim() == selector) else {
105 return Err(SettingsError::InvalidAgentSelector { name: selector.to_string() });
106 };
107
108 if !entry.user_invocable {
109 return Err(SettingsError::NonUserInvocableAgentSelector { name: selector.to_string() });
110 }
111 }
112
113 Ok(())
114}
115
116fn resolve_agent_entry(
117 project_root: &Path,
118 entry: AgentConfig,
119 default_prompts: &[crate::PromptSource],
120 default_mcps: &[McpSourceSpec],
121 index: usize,
122 seen_names: &mut HashSet<String>,
123) -> Result<AgentSpec, SettingsError> {
124 let name = entry.name.trim().to_string();
125 if name.is_empty() {
126 return Err(SettingsError::EmptyAgentName { index });
127 }
128 if name == "__default__" {
129 return Err(SettingsError::ReservedAgentName { name });
130 }
131 if !seen_names.insert(name.clone()) {
132 return Err(SettingsError::DuplicateAgentName { name });
133 }
134
135 let description = entry.description.trim().to_string();
136 if description.is_empty() {
137 return Err(SettingsError::MissingField { agent: name.clone(), field: "description".to_string() });
138 }
139
140 let model = parse_model(&name, &entry.model)?;
141 if !entry.user_invocable && !entry.agent_invocable {
142 return Err(SettingsError::NoInvocationSurface { agent: name.clone() });
143 }
144 let prompt_sources = if entry.prompts.is_empty() { default_prompts } else { &entry.prompts };
145 if prompt_sources.is_empty() {
146 return Err(SettingsError::NoPrompts { agent: name.clone() });
147 }
148
149 let prompts = Prompt::from_sources(project_root, prompt_sources)
150 .map_err(|source| SettingsError::AgentPromptSource { agent: name.clone(), source })?;
151 let mcp_sources = if entry.mcps.is_empty() { default_mcps } else { &entry.mcps };
152 let mcp_config_sources = resolve_mcp_config_sources(project_root, mcp_sources)?;
153
154 Ok(AgentSpec {
155 name,
156 description,
157 model,
158 reasoning_effort: entry.reasoning_effort,
159 prompts,
160 mcp_config_sources,
161 exposure: AgentSpecExposure { user_invocable: entry.user_invocable, agent_invocable: entry.agent_invocable },
162 tools: entry.tools,
163 })
164}
165
166fn resolve_mcp_config_sources(
167 project_root: &Path,
168 entries: &[McpSourceSpec],
169) -> Result<Vec<McpConfigSource>, SettingsError> {
170 entries
171 .iter()
172 .map(|entry| match entry {
173 McpSourceSpec::File { path, proxy } => {
174 let full_path = project_root.join(path);
175 if full_path.is_file() {
176 Ok(McpConfigSource::file(full_path, *proxy))
177 } else {
178 Err(SettingsError::InvalidMcpConfigPath { path: path.clone() })
179 }
180 }
181 McpSourceSpec::Inline { servers } => Ok(McpConfigSource::Inline(RawMcpConfig { servers: servers.clone() })),
182 })
183 .collect()
184}
185
186fn parse_model(agent: &str, model: &str) -> Result<String, SettingsError> {
187 canonicalize_model_spec(model).map_err(|error| SettingsError::InvalidModel {
188 agent: agent.to_string(),
189 model: model.to_string(),
190 error,
191 })
192}
193
194fn canonicalize_model_spec(model: &str) -> Result<String, String> {
195 let trimmed = model.trim();
196 if trimmed.is_empty() {
197 return Err("Model spec cannot be empty".to_string());
198 }
199
200 let mut canonical_parts = Vec::new();
201 for part in trimmed.split(',').map(str::trim) {
202 if part.is_empty() {
203 return Err("Model spec contains an empty entry".to_string());
204 }
205 part.parse::<LlmModel>().map_err(|error: String| error)?;
206 canonical_parts.push(part.to_string());
207 }
208
209 Ok(canonical_parts.join(","))
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215 use aether_core::agent_spec::{AgentSpecExposure, ToolFilter};
216 use std::fs;
217
218 fn create_temp_project() -> tempfile::TempDir {
219 tempfile::tempdir().unwrap()
220 }
221
222 fn write_file(dir: &Path, path: &str, content: &str) {
223 let full_path = dir.join(path);
224 if let Some(parent) = full_path.parent() {
225 fs::create_dir_all(parent).unwrap();
226 }
227 fs::write(full_path, content).unwrap();
228 }
229
230 fn make_spec(name: &str, exposure: AgentSpecExposure) -> AgentSpec {
231 AgentSpec {
232 name: name.to_string(),
233 description: format!("{name} agent"),
234 model: "anthropic:claude-sonnet-4-5".to_string(),
235 reasoning_effort: None,
236 prompts: vec![],
237 mcp_config_sources: Vec::new(),
238 exposure,
239 tools: ToolFilter::default(),
240 }
241 }
242
243 fn create_test_catalog(project_root: PathBuf) -> AgentCatalog {
244 let planner = make_spec("planner", AgentSpecExposure::both());
245 AgentCatalog::new(project_root, vec![planner], None)
246 }
247
248 fn file_sources(spec: &AgentSpec) -> Vec<(PathBuf, bool)> {
249 spec.mcp_config_sources
250 .iter()
251 .filter_map(|source| match source {
252 McpConfigSource::File { path, proxy } => Some((path.clone(), *proxy)),
253 McpConfigSource::Json(_) | McpConfigSource::Inline(_) => None,
254 })
255 .collect()
256 }
257
258 fn has_prompt_file(spec: &AgentSpec, expected: &str) -> bool {
259 spec.prompts.iter().any(|prompt| match prompt {
260 Prompt::File { path, .. } => path == expected,
261 Prompt::Text(_) | Prompt::PromptGlobs { .. } | Prompt::McpInstructions(_) => false,
262 })
263 }
264
265 #[test]
266 fn user_invocable_filters_correctly() {
267 let dir = create_temp_project();
268 let root = dir.path().to_path_buf();
269 let catalog = AgentCatalog::new(
270 root,
271 vec![
272 make_spec("planner", AgentSpecExposure::both()),
273 make_spec("internal", AgentSpecExposure::agent_only()),
274 ],
275 None,
276 );
277
278 let user_invocable: Vec<_> = catalog.user_invocable().collect();
279 assert_eq!(user_invocable.len(), 1);
280 assert_eq!(user_invocable[0].name, "planner");
281 }
282
283 #[test]
284 fn agent_invocable_filters_correctly() {
285 let dir = create_temp_project();
286 let root = dir.path().to_path_buf();
287 let catalog = AgentCatalog::new(
288 root,
289 vec![
290 make_spec("planner", AgentSpecExposure::both()),
291 make_spec("user-only", AgentSpecExposure::user_only()),
292 ],
293 None,
294 );
295
296 let agent_invocable: Vec<_> = catalog.agent_invocable().collect();
297 assert_eq!(agent_invocable.len(), 1);
298 assert_eq!(agent_invocable[0].name, "planner");
299 }
300
301 #[test]
302 fn default_agent_uses_selected_agent() {
303 let dir = create_temp_project();
304 let catalog = AgentCatalog::new(
305 dir.path().to_path_buf(),
306 vec![make_spec("first", AgentSpecExposure::both()), make_spec("second", AgentSpecExposure::both())],
307 Some("second".to_string()),
308 );
309
310 assert_eq!(catalog.default_agent().map(|spec| spec.name.as_str()), Some("second"));
311 }
312
313 #[test]
314 fn default_agent_falls_back_to_first_user_invocable() {
315 let dir = create_temp_project();
316 let catalog = AgentCatalog::new(
317 dir.path().to_path_buf(),
318 vec![
319 make_spec("internal", AgentSpecExposure::agent_only()),
320 make_spec("visible", AgentSpecExposure::user_only()),
321 ],
322 None,
323 );
324
325 assert_eq!(catalog.default_agent().map(|spec| spec.name.as_str()), Some("visible"));
326 }
327
328 #[test]
329 fn get_returns_error_for_missing_agent() {
330 let dir = create_temp_project();
331 let catalog = create_test_catalog(dir.path().to_path_buf());
332 let result = catalog.get("nonexistent");
333 assert!(matches!(result, Err(SettingsError::AgentNotFound { .. })));
334 }
335
336 #[test]
337 fn top_level_prompts_are_inherited_when_agent_prompts_are_empty() {
338 let dir = create_temp_project();
339 write_file(dir.path(), "BASE.md", "Base instructions");
340
341 let config = AetherSettings {
342 prompts: vec![crate::PromptSource::file("BASE.md")],
343 agents: vec![AgentConfig {
344 name: "planner".to_string(),
345 description: "Planner agent".to_string(),
346 model: "anthropic:claude-sonnet-4-5".to_string(),
347 user_invocable: true,
348 ..AgentConfig::default()
349 }],
350 ..AetherSettings::default()
351 };
352
353 let catalog = AgentCatalog::from_settings(dir.path(), config).unwrap();
354 let spec = catalog.resolve("planner").unwrap();
355
356 assert!(has_prompt_file(&spec, "BASE.md"));
357 }
358
359 #[test]
360 fn agent_prompts_override_top_level_prompts() {
361 let dir = create_temp_project();
362 write_file(dir.path(), "BASE.md", "Base instructions");
363 write_file(dir.path(), "AGENT.md", "Agent instructions");
364
365 let config = AetherSettings {
366 prompts: vec![crate::PromptSource::file("BASE.md")],
367 agents: vec![AgentConfig {
368 name: "planner".to_string(),
369 description: "Planner agent".to_string(),
370 model: "anthropic:claude-sonnet-4-5".to_string(),
371 user_invocable: true,
372 prompts: vec![crate::PromptSource::file("AGENT.md")],
373 ..AgentConfig::default()
374 }],
375 ..AetherSettings::default()
376 };
377
378 let catalog = AgentCatalog::from_settings(dir.path(), config).unwrap();
379 let spec = catalog.resolve("planner").unwrap();
380
381 assert!(has_prompt_file(&spec, "AGENT.md"));
382 assert!(!has_prompt_file(&spec, "BASE.md"));
383 }
384
385 #[test]
386 fn top_level_mcps_are_inherited_when_agent_mcps_are_empty() {
387 let dir = create_temp_project();
388 write_file(dir.path(), "BASE.md", "Base instructions");
389 write_file(dir.path(), "base-mcp.json", "{}");
390
391 let config = AetherSettings {
392 prompts: vec![crate::PromptSource::file("BASE.md")],
393 mcps: vec![McpSourceSpec::file("base-mcp.json")],
394 agents: vec![AgentConfig {
395 name: "planner".to_string(),
396 description: "Planner agent".to_string(),
397 model: "anthropic:claude-sonnet-4-5".to_string(),
398 user_invocable: true,
399 ..AgentConfig::default()
400 }],
401 ..AetherSettings::default()
402 };
403
404 let catalog = AgentCatalog::from_settings(dir.path(), config).unwrap();
405 let spec = catalog.resolve("planner").unwrap();
406
407 assert_eq!(file_sources(&spec), vec![(dir.path().join("base-mcp.json"), false)]);
408 }
409
410 #[test]
411 fn agent_mcps_override_top_level_mcps() {
412 let dir = create_temp_project();
413 write_file(dir.path(), "BASE.md", "Base instructions");
414 write_file(dir.path(), "base-mcp.json", "{}");
415 write_file(dir.path(), "agent-mcp.json", "{}");
416
417 let config = AetherSettings {
418 prompts: vec![crate::PromptSource::file("BASE.md")],
419 mcps: vec![McpSourceSpec::file("base-mcp.json")],
420 agents: vec![AgentConfig {
421 name: "planner".to_string(),
422 description: "Planner agent".to_string(),
423 model: "anthropic:claude-sonnet-4-5".to_string(),
424 user_invocable: true,
425 mcps: vec![McpSourceSpec::file("agent-mcp.json")],
426 ..AgentConfig::default()
427 }],
428 ..AetherSettings::default()
429 };
430
431 let catalog = AgentCatalog::from_settings(dir.path(), config).unwrap();
432 let spec = catalog.resolve("planner").unwrap();
433
434 assert_eq!(file_sources(&spec), vec![(dir.path().join("agent-mcp.json"), false)]);
435 }
436
437 #[test]
438 fn missing_top_level_and_agent_prompts_still_errors() {
439 let config = AetherSettings {
440 agents: vec![AgentConfig {
441 name: "planner".to_string(),
442 description: "Planner agent".to_string(),
443 model: "anthropic:claude-sonnet-4-5".to_string(),
444 user_invocable: true,
445 ..AgentConfig::default()
446 }],
447 ..AetherSettings::default()
448 };
449
450 let err = AgentCatalog::from_settings(Path::new("/tmp"), config).unwrap_err();
451
452 assert!(matches!(err, SettingsError::NoPrompts { agent } if agent == "planner"));
453 }
454
455 #[test]
456 fn resolve_missing_agent_returns_error() {
457 let dir = create_temp_project();
458 let catalog = create_test_catalog(dir.path().to_path_buf());
459 let result = catalog.resolve("missing");
460 assert!(matches!(result, Err(SettingsError::AgentNotFound { .. })));
461 }
462
463 #[test]
464 fn resolve_preserves_agent_mcp() {
465 let dir = create_temp_project();
466 write_file(dir.path(), "agent-mcp.json", "{}");
467
468 let mut planner = make_spec("planner", AgentSpecExposure::both());
469 planner.mcp_config_sources = vec![McpConfigSource::direct(dir.path().join("agent-mcp.json"))];
470
471 let catalog = AgentCatalog::new(dir.path().to_path_buf(), vec![planner], None);
472
473 let spec = catalog.resolve("planner").unwrap();
474 assert_eq!(file_sources(&spec), vec![(dir.path().join("agent-mcp.json"), false)]);
475 }
476
477 #[test]
478 fn resolve_no_mcp_config_is_valid() {
479 let dir = create_temp_project();
480 let catalog =
481 AgentCatalog::new(dir.path().to_path_buf(), vec![make_spec("planner", AgentSpecExposure::both())], None);
482
483 let spec = catalog.resolve("planner").unwrap();
484 assert!(spec.mcp_config_sources.is_empty());
485 }
486}