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