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(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    /// Create an empty catalog for a project with no settings.
58    pub fn empty(project_root: PathBuf) -> Self {
59        Self::new(project_root, Vec::new(), None)
60    }
61
62    /// The project root directory.
63    pub fn project_root(&self) -> &Path {
64        &self.project_root
65    }
66
67    /// Get all agent specs in the catalog.
68    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    /// Get a specific agent by name.
84    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    /// Iterate over user-invocable agents.
92    pub fn user_invocable(&self) -> impl Iterator<Item = &AgentSpec> {
93        self.specs.iter().filter(|s| s.exposure.user_invocable)
94    }
95
96    /// Iterate over agent-invocable agents.
97    pub fn agent_invocable(&self) -> impl Iterator<Item = &AgentSpec> {
98        self.specs.iter().filter(|s| s.exposure.agent_invocable)
99    }
100
101    /// Resolve and return a named agent spec ready for runtime use.
102    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}