Skip to main content

aether_project/
agent_catalog.rs

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/// A resolved catalog of agents from a project.
14///
15/// This type owns project-relative resolution context.
16#[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    /// Create an empty catalog for a project with no settings.
65    pub fn empty(project_root: PathBuf) -> Self {
66        Self::new(project_root, Vec::new(), None)
67    }
68
69    /// The project root directory.
70    pub fn project_root(&self) -> &Path {
71        &self.project_root
72    }
73
74    /// Get all agent specs in the catalog.
75    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    /// Get a specific agent by name.
91    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    /// Iterate over user-invocable agents.
99    pub fn user_invocable(&self) -> impl Iterator<Item = &AgentSpec> {
100        self.specs.iter().filter(|s| s.exposure.user_invocable)
101    }
102
103    /// Iterate over agent-invocable agents.
104    pub fn agent_invocable(&self) -> impl Iterator<Item = &AgentSpec> {
105        self.specs.iter().filter(|s| s.exposure.agent_invocable)
106    }
107
108    /// Resolve and return a named agent spec ready for runtime use.
109    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}