1use crate::aether_settings::{project_settings_exist, user_settings_exist};
2use crate::error::SettingsError;
3use crate::{AetherSettings, AgentConfig, McpFileSpec, McpSourceSpec};
4use aether_core::agent_spec::{AgentSpec, AgentSpecExposure, McpConfigSource};
5use aether_core::core::Prompt;
6use llm::ProviderConnectionOverrides;
7use llm::catalog::{ModelSpec, ModelSpecError};
8use mcp_utils::client::McpConfig;
9use std::collections::HashSet;
10use std::path::{Path, PathBuf};
11use utils::variables::VarError;
12
13#[derive(Debug, Clone)]
17pub struct AgentCatalog {
18 project_root: PathBuf,
19 specs: Vec<AgentSpec>,
20 selected_agent: Option<String>,
21}
22
23impl AgentCatalog {
24 pub fn load_default(project_root: &Path) -> Result<Option<Self>, SettingsError> {
25 let has_default_settings = user_settings_exist() || project_settings_exist(project_root);
26 let settings = AetherSettings::load_default(project_root)?;
27 if settings.agents.is_empty() && !has_default_settings {
28 return Ok(None);
29 }
30
31 let catalog = Self::from_settings(project_root, settings)?;
32 if catalog.user_invocable().next().is_none() {
33 return Err(SettingsError::NoUserInvocableAgents);
34 }
35
36 Ok(Some(catalog))
37 }
38
39 pub fn from_settings_or_empty(project_root: &Path, settings: AetherSettings) -> Result<Self, SettingsError> {
40 if settings.agents.is_empty() {
41 return Ok(Self::empty(project_root.to_path_buf()));
42 }
43 Self::from_settings(project_root, settings)
44 }
45
46 pub fn from_settings(project_root: &Path, settings: AetherSettings) -> Result<Self, SettingsError> {
47 validate_selected_agent(&settings)?;
48 let selected_agent =
49 settings.agent.as_deref().map(str::trim).filter(|name| !name.is_empty()).map(str::to_string);
50 let defaults = AgentDefaults { prompts: settings.prompts, mcps: settings.mcps, providers: settings.providers };
51 let mut seen_names = HashSet::new();
52 let mut specs = Vec::with_capacity(settings.agents.len());
53 for (index, entry) in settings.agents.into_iter().enumerate() {
54 specs.push(resolve_agent_entry(project_root, entry, &defaults, index, &mut seen_names)?);
55 }
56
57 Ok(Self::new(project_root.to_path_buf(), specs, selected_agent))
58 }
59
60 pub(crate) fn new(project_root: PathBuf, specs: Vec<AgentSpec>, selected_agent: Option<String>) -> Self {
61 Self { project_root, specs, selected_agent }
62 }
63
64 pub fn empty(project_root: PathBuf) -> Self {
66 Self::new(project_root, Vec::new(), None)
67 }
68
69 pub fn project_root(&self) -> &Path {
71 &self.project_root
72 }
73
74 pub fn all(&self) -> &[AgentSpec] {
76 &self.specs
77 }
78
79 pub fn selected_agent(&self) -> Option<&str> {
80 self.selected_agent.as_deref()
81 }
82
83 pub fn default_agent(&self) -> Option<&AgentSpec> {
84 self.selected_agent
85 .as_deref()
86 .and_then(|name| self.specs.iter().find(|spec| spec.name == name))
87 .or_else(|| self.user_invocable().next())
88 }
89
90 pub fn get(&self, name: &str) -> Result<&AgentSpec, SettingsError> {
92 self.specs
93 .iter()
94 .find(|spec| spec.name == name)
95 .ok_or_else(|| SettingsError::AgentNotFound { name: name.to_string() })
96 }
97
98 pub fn user_invocable(&self) -> impl Iterator<Item = &AgentSpec> {
100 self.specs.iter().filter(|s| s.exposure.user_invocable)
101 }
102
103 pub fn agent_invocable(&self) -> impl Iterator<Item = &AgentSpec> {
105 self.specs.iter().filter(|s| s.exposure.agent_invocable)
106 }
107
108 pub fn resolve(&self, name: &str) -> Result<AgentSpec, SettingsError> {
110 self.get(name).cloned()
111 }
112}
113
114struct AgentDefaults {
115 prompts: Vec<crate::PromptSource>,
116 mcps: Vec<McpSourceSpec>,
117 providers: ProviderConnectionOverrides,
118}
119
120fn validate_selected_agent(settings: &AetherSettings) -> Result<(), SettingsError> {
121 if settings.agents.is_empty() {
122 return Err(SettingsError::EmptyAgents);
123 }
124
125 if let Some(agent) = settings.agent.as_deref() {
126 let selector = agent.trim();
127 let Some(entry) = settings.agents.iter().find(|entry| entry.name.trim() == selector) else {
128 return Err(SettingsError::InvalidAgentSelector { name: selector.to_string() });
129 };
130
131 if !entry.user_invocable {
132 return Err(SettingsError::NonUserInvocableAgentSelector { name: selector.to_string() });
133 }
134 }
135
136 Ok(())
137}
138
139fn resolve_agent_entry(
140 project_root: &Path,
141 entry: AgentConfig,
142 defaults: &AgentDefaults,
143 index: usize,
144 seen_names: &mut HashSet<String>,
145) -> Result<AgentSpec, SettingsError> {
146 let name = entry.name.trim().to_string();
147 if name.is_empty() {
148 return Err(SettingsError::EmptyAgentName { index });
149 }
150 if name == "__default__" {
151 return Err(SettingsError::ReservedAgentName { name });
152 }
153 if !seen_names.insert(name.clone()) {
154 return Err(SettingsError::DuplicateAgentName { name });
155 }
156
157 let description = entry.description.trim().to_string();
158 if description.is_empty() {
159 return Err(SettingsError::MissingField { agent: name.clone(), field: "description".to_string() });
160 }
161
162 let model = parse_model(&name, &entry.model)?;
163 model
164 .validate_reasoning_effort(entry.reasoning_effort)
165 .map_err(|source| SettingsError::InvalidReasoningEffort { agent: name.clone(), source })?;
166 if entry.context_window == Some(0) {
167 return Err(SettingsError::InvalidContextWindow { agent: name.clone(), context_window: 0 });
168 }
169 if !entry.user_invocable && !entry.agent_invocable {
170 return Err(SettingsError::NoInvocationSurface { agent: name.clone() });
171 }
172 let prompt_sources = if entry.prompts.is_empty() { &defaults.prompts } else { &entry.prompts };
173 let prompts = Prompt::from_sources(project_root, prompt_sources)
174 .map_err(|source| SettingsError::AgentPromptSource { agent: name.clone(), source })?;
175 if prompts.is_empty() {
176 return Err(if prompt_sources.is_empty() {
177 SettingsError::NoPromptsDeclared { agent: name.clone() }
178 } else {
179 SettingsError::AllOptionalPromptsMissing { agent: name.clone() }
180 });
181 }
182 let mcp_sources = if entry.mcps.is_empty() { &defaults.mcps } else { &entry.mcps };
183 let mcp_config_sources = resolve_mcp_config_sources(project_root, mcp_sources)?;
184 let mut provider_connections = defaults.providers.clone();
185 provider_connections.merge(entry.providers);
186
187 Ok(AgentSpec {
188 name,
189 description,
190 model: model.to_string(),
191 reasoning_effort: entry.reasoning_effort,
192 model_settings: entry.model_settings,
193 context_window: entry.context_window,
194 prompts,
195 provider_connections,
196 mcp_config_sources,
197 exposure: AgentSpecExposure { user_invocable: entry.user_invocable, agent_invocable: entry.agent_invocable },
198 tools: entry.tools,
199 })
200}
201
202fn resolve_mcp_config_sources(
203 workspace_root: &Path,
204 entries: &[McpSourceSpec],
205) -> Result<Vec<McpConfigSource>, SettingsError> {
206 entries
207 .iter()
208 .filter_map(|entry| match entry {
209 McpSourceSpec::File(McpFileSpec { path, proxy, optional }) => match path.resolve(workspace_root) {
210 Ok(full_path) => {
211 if full_path.is_file() {
212 Some(Ok(McpConfigSource::file(full_path, *proxy)))
213 } else if *optional {
214 None
215 } else {
216 Some(Err(SettingsError::InvalidMcpConfigPath { path: path.as_authored().to_string() }))
217 }
218 }
219 Err(VarError::NotFound(variable)) if *optional => {
220 tracing::warn!(
221 "Skipping optional MCP config '{}': variable '{variable}' is not defined",
222 path.as_authored()
223 );
224 None
225 }
226 Err(VarError::NotFound(variable)) => Some(Err(SettingsError::UnresolvedMcpConfigVariable {
227 path: path.as_authored().to_string(),
228 variable,
229 })),
230 },
231 McpSourceSpec::Inline { servers } => Some(Ok(McpConfigSource::Inline(McpConfig::new(servers.clone())))),
232 })
233 .collect()
234}
235
236fn parse_model(agent: &str, model: &str) -> Result<ModelSpec, SettingsError> {
237 model.parse().map_err(|error: ModelSpecError| SettingsError::InvalidModel {
238 agent: agent.to_string(),
239 model: model.to_string(),
240 error: error.to_string(),
241 })
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247 use aether_core::agent_spec::{AgentSpecExposure, ToolFilter};
248 use llm::ModelSettings;
249 use std::fs;
250
251 fn create_temp_project() -> tempfile::TempDir {
252 tempfile::tempdir().unwrap()
253 }
254
255 fn write_file(dir: &Path, path: &str, content: &str) {
256 let full_path = dir.join(path);
257 if let Some(parent) = full_path.parent() {
258 fs::create_dir_all(parent).unwrap();
259 }
260 fs::write(full_path, content).unwrap();
261 }
262
263 fn make_spec(name: &str, exposure: AgentSpecExposure) -> AgentSpec {
264 AgentSpec {
265 name: name.to_string(),
266 description: format!("{name} agent"),
267 model: "anthropic:claude-sonnet-4-5".to_string(),
268 reasoning_effort: None,
269 model_settings: ModelSettings::default(),
270 context_window: None,
271 prompts: vec![],
272 provider_connections: ProviderConnectionOverrides::default(),
273 mcp_config_sources: Vec::new(),
274 exposure,
275 tools: ToolFilter::default(),
276 }
277 }
278
279 fn create_test_catalog(project_root: PathBuf) -> AgentCatalog {
280 let planner = make_spec("planner", AgentSpecExposure::both());
281 AgentCatalog::new(project_root, vec![planner], None)
282 }
283
284 fn file_sources(spec: &AgentSpec) -> Vec<(PathBuf, bool)> {
285 spec.mcp_config_sources
286 .iter()
287 .filter_map(|source| match source {
288 McpConfigSource::File { path, proxy } => Some((path.clone(), *proxy)),
289 McpConfigSource::Json(_) | McpConfigSource::Inline(_) => None,
290 })
291 .collect()
292 }
293
294 fn has_prompt_file(spec: &AgentSpec, expected: &str) -> bool {
295 spec.prompts.iter().any(|prompt| match prompt {
296 Prompt::File { path, .. } => path.ends_with(expected),
297 Prompt::Text(_) | Prompt::McpInstructions(_) => false,
298 })
299 }
300
301 #[test]
302 fn user_invocable_filters_correctly() {
303 let dir = create_temp_project();
304 let root = dir.path().to_path_buf();
305 let catalog = AgentCatalog::new(
306 root,
307 vec![
308 make_spec("planner", AgentSpecExposure::both()),
309 make_spec("internal", AgentSpecExposure::agent_only()),
310 ],
311 None,
312 );
313
314 let user_invocable: Vec<_> = catalog.user_invocable().collect();
315 assert_eq!(user_invocable.len(), 1);
316 assert_eq!(user_invocable[0].name, "planner");
317 }
318
319 #[test]
320 fn agent_invocable_filters_correctly() {
321 let dir = create_temp_project();
322 let root = dir.path().to_path_buf();
323 let catalog = AgentCatalog::new(
324 root,
325 vec![
326 make_spec("planner", AgentSpecExposure::both()),
327 make_spec("user-only", AgentSpecExposure::user_only()),
328 ],
329 None,
330 );
331
332 let agent_invocable: Vec<_> = catalog.agent_invocable().collect();
333 assert_eq!(agent_invocable.len(), 1);
334 assert_eq!(agent_invocable[0].name, "planner");
335 }
336
337 #[test]
338 fn default_agent_uses_selected_agent() {
339 let dir = create_temp_project();
340 let catalog = AgentCatalog::new(
341 dir.path().to_path_buf(),
342 vec![make_spec("first", AgentSpecExposure::both()), make_spec("second", AgentSpecExposure::both())],
343 Some("second".to_string()),
344 );
345
346 assert_eq!(catalog.default_agent().map(|spec| spec.name.as_str()), Some("second"));
347 }
348
349 #[test]
350 fn default_agent_falls_back_to_first_user_invocable() {
351 let dir = create_temp_project();
352 let catalog = AgentCatalog::new(
353 dir.path().to_path_buf(),
354 vec![
355 make_spec("internal", AgentSpecExposure::agent_only()),
356 make_spec("visible", AgentSpecExposure::user_only()),
357 ],
358 None,
359 );
360
361 assert_eq!(catalog.default_agent().map(|spec| spec.name.as_str()), Some("visible"));
362 }
363
364 #[test]
365 fn get_returns_error_for_missing_agent() {
366 let dir = create_temp_project();
367 let catalog = create_test_catalog(dir.path().to_path_buf());
368 let result = catalog.get("nonexistent");
369 assert!(matches!(result, Err(SettingsError::AgentNotFound { .. })));
370 }
371
372 #[test]
373 fn agent_rejects_reasoning_effort_unsupported_by_model() {
374 let dir = create_temp_project();
375 write_file(dir.path(), "BASE.md", "Base instructions");
376 let config = AetherSettings {
377 agents: vec![AgentConfig {
378 name: "planner".to_string(),
379 description: "Planner agent".to_string(),
380 model: "anthropic:claude-opus-4-6".to_string(),
381 reasoning_effort: Some(llm::ReasoningEffort::Xhigh),
382 user_invocable: true,
383 prompts: vec![crate::PromptSource::file("BASE.md")],
384 ..AgentConfig::default()
385 }],
386 ..AetherSettings::default()
387 };
388
389 let error = AgentCatalog::from_settings(dir.path(), config).unwrap_err();
390
391 assert!(matches!(error, SettingsError::InvalidReasoningEffort { .. }));
392 }
393
394 #[test]
395 fn agent_context_window_is_resolved_into_spec() {
396 let dir = create_temp_project();
397 write_file(dir.path(), "BASE.md", "Base instructions");
398
399 let config = AetherSettings {
400 agents: vec![AgentConfig {
401 name: "planner".to_string(),
402 description: "Planner agent".to_string(),
403 model: "anthropic:claude-sonnet-4-5".to_string(),
404 context_window: Some(200_000),
405 user_invocable: true,
406 prompts: vec![crate::PromptSource::file("BASE.md")],
407 ..AgentConfig::default()
408 }],
409 ..AetherSettings::default()
410 };
411
412 let catalog = AgentCatalog::from_settings(dir.path(), config).unwrap();
413 let spec = catalog.resolve("planner").unwrap();
414
415 assert_eq!(spec.context_window, Some(200_000));
416 }
417
418 #[test]
419 fn agent_model_settings_resolve_from_config_json() {
420 let dir = create_temp_project();
421 write_file(dir.path(), "BASE.md", "Base instructions");
422
423 let json = r#"{
424 "agents": [{
425 "name": "judge",
426 "description": "Judge agent",
427 "model": "anthropic:claude-sonnet-4-5",
428 "userInvocable": true,
429 "prompts": ["BASE.md"],
430 "modelSettings": { "temperature": 0, "topP": 0.9, "maxTokens": 1024 }
431 }]
432 }"#;
433
434 let config: AetherSettings = serde_json::from_str(json).unwrap();
435 let catalog = AgentCatalog::from_settings(dir.path(), config).unwrap();
436 let spec = catalog.resolve("judge").unwrap();
437
438 assert_eq!(
439 spec.model_settings,
440 ModelSettings { temperature: Some(0.0), top_p: Some(0.9), max_tokens: Some(1024) }
441 );
442 }
443
444 #[test]
445 fn agent_context_window_rejects_zero() {
446 let config = AetherSettings {
447 agents: vec![AgentConfig {
448 name: "planner".to_string(),
449 description: "Planner agent".to_string(),
450 model: "anthropic:claude-sonnet-4-5".to_string(),
451 context_window: Some(0),
452 user_invocable: true,
453 ..AgentConfig::default()
454 }],
455 ..AetherSettings::default()
456 };
457
458 let err = AgentCatalog::from_settings(Path::new("/tmp"), config).unwrap_err();
459
460 assert!(matches!(
461 err,
462 SettingsError::InvalidContextWindow { agent, context_window: 0 } if agent == "planner"
463 ));
464 }
465
466 #[test]
467 fn top_level_prompts_are_inherited_when_agent_prompts_are_empty() {
468 let dir = create_temp_project();
469 write_file(dir.path(), "BASE.md", "Base instructions");
470
471 let config = AetherSettings {
472 prompts: vec![crate::PromptSource::file("BASE.md")],
473 agents: vec![AgentConfig {
474 name: "planner".to_string(),
475 description: "Planner agent".to_string(),
476 model: "anthropic:claude-sonnet-4-5".to_string(),
477 user_invocable: true,
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!(has_prompt_file(&spec, "BASE.md"));
487 }
488
489 #[test]
490 fn agent_prompts_override_top_level_prompts() {
491 let dir = create_temp_project();
492 write_file(dir.path(), "BASE.md", "Base instructions");
493 write_file(dir.path(), "AGENT.md", "Agent instructions");
494
495 let config = AetherSettings {
496 prompts: vec![crate::PromptSource::file("BASE.md")],
497 agents: vec![AgentConfig {
498 name: "planner".to_string(),
499 description: "Planner agent".to_string(),
500 model: "anthropic:claude-sonnet-4-5".to_string(),
501 user_invocable: true,
502 prompts: vec![crate::PromptSource::file("AGENT.md")],
503 ..AgentConfig::default()
504 }],
505 ..AetherSettings::default()
506 };
507
508 let catalog = AgentCatalog::from_settings(dir.path(), config).unwrap();
509 let spec = catalog.resolve("planner").unwrap();
510
511 assert!(has_prompt_file(&spec, "AGENT.md"));
512 assert!(!has_prompt_file(&spec, "BASE.md"));
513 }
514
515 #[test]
516 fn top_level_mcps_are_inherited_when_agent_mcps_are_empty() {
517 let dir = create_temp_project();
518 write_file(dir.path(), "BASE.md", "Base instructions");
519 write_file(dir.path(), "base-mcp.json", "{}");
520
521 let config = AetherSettings {
522 prompts: vec![crate::PromptSource::file("BASE.md")],
523 mcps: vec![McpSourceSpec::file("base-mcp.json")],
524 agents: vec![AgentConfig {
525 name: "planner".to_string(),
526 description: "Planner agent".to_string(),
527 model: "anthropic:claude-sonnet-4-5".to_string(),
528 user_invocable: true,
529 ..AgentConfig::default()
530 }],
531 ..AetherSettings::default()
532 };
533
534 let catalog = AgentCatalog::from_settings(dir.path(), config).unwrap();
535 let spec = catalog.resolve("planner").unwrap();
536
537 assert_eq!(file_sources(&spec), vec![(dir.path().join("base-mcp.json"), false)]);
538 }
539
540 #[test]
541 fn agent_mcps_override_top_level_mcps() {
542 let dir = create_temp_project();
543 write_file(dir.path(), "BASE.md", "Base instructions");
544 write_file(dir.path(), "base-mcp.json", "{}");
545 write_file(dir.path(), "agent-mcp.json", "{}");
546
547 let config = AetherSettings {
548 prompts: vec![crate::PromptSource::file("BASE.md")],
549 mcps: vec![McpSourceSpec::file("base-mcp.json")],
550 agents: vec![AgentConfig {
551 name: "planner".to_string(),
552 description: "Planner agent".to_string(),
553 model: "anthropic:claude-sonnet-4-5".to_string(),
554 user_invocable: true,
555 mcps: vec![McpSourceSpec::file("agent-mcp.json")],
556 ..AgentConfig::default()
557 }],
558 ..AetherSettings::default()
559 };
560
561 let catalog = AgentCatalog::from_settings(dir.path(), config).unwrap();
562 let spec = catalog.resolve("planner").unwrap();
563
564 assert_eq!(file_sources(&spec), vec![(dir.path().join("agent-mcp.json"), false)]);
565 }
566
567 #[test]
568 fn missing_top_level_and_agent_prompts_still_errors() {
569 let config = AetherSettings {
570 agents: vec![AgentConfig {
571 name: "planner".to_string(),
572 description: "Planner agent".to_string(),
573 model: "anthropic:claude-sonnet-4-5".to_string(),
574 user_invocable: true,
575 ..AgentConfig::default()
576 }],
577 ..AetherSettings::default()
578 };
579
580 let err = AgentCatalog::from_settings(Path::new("/tmp"), config).unwrap_err();
581
582 assert!(matches!(err, SettingsError::NoPromptsDeclared { agent } if agent == "planner"));
583 }
584
585 #[test]
586 fn resolve_missing_agent_returns_error() {
587 let dir = create_temp_project();
588 let catalog = create_test_catalog(dir.path().to_path_buf());
589 let result = catalog.resolve("missing");
590 assert!(matches!(result, Err(SettingsError::AgentNotFound { .. })));
591 }
592
593 #[test]
594 fn resolve_preserves_agent_mcp() {
595 let dir = create_temp_project();
596 write_file(dir.path(), "agent-mcp.json", "{}");
597
598 let mut planner = make_spec("planner", AgentSpecExposure::both());
599 planner.mcp_config_sources = vec![McpConfigSource::direct(dir.path().join("agent-mcp.json"))];
600
601 let catalog = AgentCatalog::new(dir.path().to_path_buf(), vec![planner], None);
602
603 let spec = catalog.resolve("planner").unwrap();
604 assert_eq!(file_sources(&spec), vec![(dir.path().join("agent-mcp.json"), false)]);
605 }
606
607 #[test]
608 fn resolve_no_mcp_config_is_valid() {
609 let dir = create_temp_project();
610 let catalog =
611 AgentCatalog::new(dir.path().to_path_buf(), vec![make_spec("planner", AgentSpecExposure::both())], None);
612
613 let spec = catalog.resolve("planner").unwrap();
614 assert!(spec.mcp_config_sources.is_empty());
615 }
616}