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