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 context_window: entry.context_window,
182 prompts,
183 provider_connections,
184 mcp_config_sources,
185 exposure: AgentSpecExposure { user_invocable: entry.user_invocable, agent_invocable: entry.agent_invocable },
186 tools: entry.tools,
187 })
188}
189
190fn resolve_mcp_config_sources(
191 workspace_root: &Path,
192 entries: &[McpSourceSpec],
193) -> Result<Vec<McpConfigSource>, SettingsError> {
194 entries
195 .iter()
196 .filter_map(|entry| match entry {
197 McpSourceSpec::File(McpFileSpec { path, proxy, optional }) => match path.resolve(workspace_root) {
198 Ok(full_path) => {
199 if full_path.is_file() {
200 Some(Ok(McpConfigSource::file(full_path, *proxy)))
201 } else if *optional {
202 None
203 } else {
204 Some(Err(SettingsError::InvalidMcpConfigPath { path: path.as_authored().to_string() }))
205 }
206 }
207 Err(VarError::NotFound(variable)) if *optional => {
208 tracing::warn!(
209 "Skipping optional MCP config '{}': variable '{variable}' is not defined",
210 path.as_authored()
211 );
212 None
213 }
214 Err(VarError::NotFound(variable)) => Some(Err(SettingsError::UnresolvedMcpConfigVariable {
215 path: path.as_authored().to_string(),
216 variable,
217 })),
218 },
219 McpSourceSpec::Inline { servers } => Some(Ok(McpConfigSource::Inline(McpConfig::new(servers.clone())))),
220 })
221 .collect()
222}
223
224fn parse_model(agent: &str, model: &str) -> Result<String, SettingsError> {
225 canonicalize_model_spec(model).map_err(|error| SettingsError::InvalidModel {
226 agent: agent.to_string(),
227 model: model.to_string(),
228 error,
229 })
230}
231
232fn canonicalize_model_spec(model: &str) -> Result<String, String> {
233 let trimmed = model.trim();
234 if trimmed.is_empty() {
235 return Err("Model spec cannot be empty".to_string());
236 }
237
238 let mut canonical_parts = Vec::new();
239 for part in trimmed.split(',').map(str::trim) {
240 if part.is_empty() {
241 return Err("Model spec contains an empty entry".to_string());
242 }
243 part.parse::<LlmModel>().map_err(|error: String| error)?;
244 canonical_parts.push(part.to_string());
245 }
246
247 Ok(canonical_parts.join(","))
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253 use aether_core::agent_spec::{AgentSpecExposure, ToolFilter};
254 use std::fs;
255
256 fn create_temp_project() -> tempfile::TempDir {
257 tempfile::tempdir().unwrap()
258 }
259
260 fn write_file(dir: &Path, path: &str, content: &str) {
261 let full_path = dir.join(path);
262 if let Some(parent) = full_path.parent() {
263 fs::create_dir_all(parent).unwrap();
264 }
265 fs::write(full_path, content).unwrap();
266 }
267
268 fn make_spec(name: &str, exposure: AgentSpecExposure) -> AgentSpec {
269 AgentSpec {
270 name: name.to_string(),
271 description: format!("{name} agent"),
272 model: "anthropic:claude-sonnet-4-5".to_string(),
273 reasoning_effort: None,
274 context_window: None,
275 prompts: vec![],
276 provider_connections: ProviderConnectionOverrides::default(),
277 mcp_config_sources: Vec::new(),
278 exposure,
279 tools: ToolFilter::default(),
280 }
281 }
282
283 fn create_test_catalog(project_root: PathBuf) -> AgentCatalog {
284 let planner = make_spec("planner", AgentSpecExposure::both());
285 AgentCatalog::new(project_root, vec![planner], None)
286 }
287
288 fn file_sources(spec: &AgentSpec) -> Vec<(PathBuf, bool)> {
289 spec.mcp_config_sources
290 .iter()
291 .filter_map(|source| match source {
292 McpConfigSource::File { path, proxy } => Some((path.clone(), *proxy)),
293 McpConfigSource::Json(_) | McpConfigSource::Inline(_) => None,
294 })
295 .collect()
296 }
297
298 fn has_prompt_file(spec: &AgentSpec, expected: &str) -> bool {
299 spec.prompts.iter().any(|prompt| match prompt {
300 Prompt::File { path, .. } => path.ends_with(expected),
301 Prompt::Text(_) | Prompt::McpInstructions(_) => false,
302 })
303 }
304
305 #[test]
306 fn user_invocable_filters_correctly() {
307 let dir = create_temp_project();
308 let root = dir.path().to_path_buf();
309 let catalog = AgentCatalog::new(
310 root,
311 vec![
312 make_spec("planner", AgentSpecExposure::both()),
313 make_spec("internal", AgentSpecExposure::agent_only()),
314 ],
315 None,
316 );
317
318 let user_invocable: Vec<_> = catalog.user_invocable().collect();
319 assert_eq!(user_invocable.len(), 1);
320 assert_eq!(user_invocable[0].name, "planner");
321 }
322
323 #[test]
324 fn agent_invocable_filters_correctly() {
325 let dir = create_temp_project();
326 let root = dir.path().to_path_buf();
327 let catalog = AgentCatalog::new(
328 root,
329 vec![
330 make_spec("planner", AgentSpecExposure::both()),
331 make_spec("user-only", AgentSpecExposure::user_only()),
332 ],
333 None,
334 );
335
336 let agent_invocable: Vec<_> = catalog.agent_invocable().collect();
337 assert_eq!(agent_invocable.len(), 1);
338 assert_eq!(agent_invocable[0].name, "planner");
339 }
340
341 #[test]
342 fn default_agent_uses_selected_agent() {
343 let dir = create_temp_project();
344 let catalog = AgentCatalog::new(
345 dir.path().to_path_buf(),
346 vec![make_spec("first", AgentSpecExposure::both()), make_spec("second", AgentSpecExposure::both())],
347 Some("second".to_string()),
348 );
349
350 assert_eq!(catalog.default_agent().map(|spec| spec.name.as_str()), Some("second"));
351 }
352
353 #[test]
354 fn default_agent_falls_back_to_first_user_invocable() {
355 let dir = create_temp_project();
356 let catalog = AgentCatalog::new(
357 dir.path().to_path_buf(),
358 vec![
359 make_spec("internal", AgentSpecExposure::agent_only()),
360 make_spec("visible", AgentSpecExposure::user_only()),
361 ],
362 None,
363 );
364
365 assert_eq!(catalog.default_agent().map(|spec| spec.name.as_str()), Some("visible"));
366 }
367
368 #[test]
369 fn get_returns_error_for_missing_agent() {
370 let dir = create_temp_project();
371 let catalog = create_test_catalog(dir.path().to_path_buf());
372 let result = catalog.get("nonexistent");
373 assert!(matches!(result, Err(SettingsError::AgentNotFound { .. })));
374 }
375
376 #[test]
377 fn agent_context_window_is_resolved_into_spec() {
378 let dir = create_temp_project();
379 write_file(dir.path(), "BASE.md", "Base instructions");
380
381 let config = AetherSettings {
382 agents: vec![AgentConfig {
383 name: "planner".to_string(),
384 description: "Planner agent".to_string(),
385 model: "anthropic:claude-sonnet-4-5".to_string(),
386 context_window: Some(200_000),
387 user_invocable: true,
388 prompts: vec![crate::PromptSource::file("BASE.md")],
389 ..AgentConfig::default()
390 }],
391 ..AetherSettings::default()
392 };
393
394 let catalog = AgentCatalog::from_settings(dir.path(), config).unwrap();
395 let spec = catalog.resolve("planner").unwrap();
396
397 assert_eq!(spec.context_window, Some(200_000));
398 }
399
400 #[test]
401 fn agent_context_window_rejects_zero() {
402 let config = AetherSettings {
403 agents: vec![AgentConfig {
404 name: "planner".to_string(),
405 description: "Planner agent".to_string(),
406 model: "anthropic:claude-sonnet-4-5".to_string(),
407 context_window: Some(0),
408 user_invocable: true,
409 ..AgentConfig::default()
410 }],
411 ..AetherSettings::default()
412 };
413
414 let err = AgentCatalog::from_settings(Path::new("/tmp"), config).unwrap_err();
415
416 assert!(matches!(
417 err,
418 SettingsError::InvalidContextWindow { agent, context_window: 0 } if agent == "planner"
419 ));
420 }
421
422 #[test]
423 fn top_level_prompts_are_inherited_when_agent_prompts_are_empty() {
424 let dir = create_temp_project();
425 write_file(dir.path(), "BASE.md", "Base instructions");
426
427 let config = AetherSettings {
428 prompts: vec![crate::PromptSource::file("BASE.md")],
429 agents: vec![AgentConfig {
430 name: "planner".to_string(),
431 description: "Planner agent".to_string(),
432 model: "anthropic:claude-sonnet-4-5".to_string(),
433 user_invocable: true,
434 ..AgentConfig::default()
435 }],
436 ..AetherSettings::default()
437 };
438
439 let catalog = AgentCatalog::from_settings(dir.path(), config).unwrap();
440 let spec = catalog.resolve("planner").unwrap();
441
442 assert!(has_prompt_file(&spec, "BASE.md"));
443 }
444
445 #[test]
446 fn agent_prompts_override_top_level_prompts() {
447 let dir = create_temp_project();
448 write_file(dir.path(), "BASE.md", "Base instructions");
449 write_file(dir.path(), "AGENT.md", "Agent instructions");
450
451 let config = AetherSettings {
452 prompts: vec![crate::PromptSource::file("BASE.md")],
453 agents: vec![AgentConfig {
454 name: "planner".to_string(),
455 description: "Planner agent".to_string(),
456 model: "anthropic:claude-sonnet-4-5".to_string(),
457 user_invocable: true,
458 prompts: vec![crate::PromptSource::file("AGENT.md")],
459 ..AgentConfig::default()
460 }],
461 ..AetherSettings::default()
462 };
463
464 let catalog = AgentCatalog::from_settings(dir.path(), config).unwrap();
465 let spec = catalog.resolve("planner").unwrap();
466
467 assert!(has_prompt_file(&spec, "AGENT.md"));
468 assert!(!has_prompt_file(&spec, "BASE.md"));
469 }
470
471 #[test]
472 fn top_level_mcps_are_inherited_when_agent_mcps_are_empty() {
473 let dir = create_temp_project();
474 write_file(dir.path(), "BASE.md", "Base instructions");
475 write_file(dir.path(), "base-mcp.json", "{}");
476
477 let config = AetherSettings {
478 prompts: vec![crate::PromptSource::file("BASE.md")],
479 mcps: vec![McpSourceSpec::file("base-mcp.json")],
480 agents: vec![AgentConfig {
481 name: "planner".to_string(),
482 description: "Planner agent".to_string(),
483 model: "anthropic:claude-sonnet-4-5".to_string(),
484 user_invocable: true,
485 ..AgentConfig::default()
486 }],
487 ..AetherSettings::default()
488 };
489
490 let catalog = AgentCatalog::from_settings(dir.path(), config).unwrap();
491 let spec = catalog.resolve("planner").unwrap();
492
493 assert_eq!(file_sources(&spec), vec![(dir.path().join("base-mcp.json"), false)]);
494 }
495
496 #[test]
497 fn agent_mcps_override_top_level_mcps() {
498 let dir = create_temp_project();
499 write_file(dir.path(), "BASE.md", "Base instructions");
500 write_file(dir.path(), "base-mcp.json", "{}");
501 write_file(dir.path(), "agent-mcp.json", "{}");
502
503 let config = AetherSettings {
504 prompts: vec![crate::PromptSource::file("BASE.md")],
505 mcps: vec![McpSourceSpec::file("base-mcp.json")],
506 agents: vec![AgentConfig {
507 name: "planner".to_string(),
508 description: "Planner agent".to_string(),
509 model: "anthropic:claude-sonnet-4-5".to_string(),
510 user_invocable: true,
511 mcps: vec![McpSourceSpec::file("agent-mcp.json")],
512 ..AgentConfig::default()
513 }],
514 ..AetherSettings::default()
515 };
516
517 let catalog = AgentCatalog::from_settings(dir.path(), config).unwrap();
518 let spec = catalog.resolve("planner").unwrap();
519
520 assert_eq!(file_sources(&spec), vec![(dir.path().join("agent-mcp.json"), false)]);
521 }
522
523 #[test]
524 fn missing_top_level_and_agent_prompts_still_errors() {
525 let config = AetherSettings {
526 agents: vec![AgentConfig {
527 name: "planner".to_string(),
528 description: "Planner agent".to_string(),
529 model: "anthropic:claude-sonnet-4-5".to_string(),
530 user_invocable: true,
531 ..AgentConfig::default()
532 }],
533 ..AetherSettings::default()
534 };
535
536 let err = AgentCatalog::from_settings(Path::new("/tmp"), config).unwrap_err();
537
538 assert!(matches!(err, SettingsError::NoPromptsDeclared { agent } if agent == "planner"));
539 }
540
541 #[test]
542 fn resolve_missing_agent_returns_error() {
543 let dir = create_temp_project();
544 let catalog = create_test_catalog(dir.path().to_path_buf());
545 let result = catalog.resolve("missing");
546 assert!(matches!(result, Err(SettingsError::AgentNotFound { .. })));
547 }
548
549 #[test]
550 fn resolve_preserves_agent_mcp() {
551 let dir = create_temp_project();
552 write_file(dir.path(), "agent-mcp.json", "{}");
553
554 let mut planner = make_spec("planner", AgentSpecExposure::both());
555 planner.mcp_config_sources = vec![McpConfigSource::direct(dir.path().join("agent-mcp.json"))];
556
557 let catalog = AgentCatalog::new(dir.path().to_path_buf(), vec![planner], None);
558
559 let spec = catalog.resolve("planner").unwrap();
560 assert_eq!(file_sources(&spec), vec![(dir.path().join("agent-mcp.json"), false)]);
561 }
562
563 #[test]
564 fn resolve_no_mcp_config_is_valid() {
565 let dir = create_temp_project();
566 let catalog =
567 AgentCatalog::new(dir.path().to_path_buf(), vec![make_spec("planner", AgentSpecExposure::both())], None);
568
569 let spec = catalog.resolve("planner").unwrap();
570 assert!(spec.mcp_config_sources.is_empty());
571 }
572}