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