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::{LlmModel, ProviderConnectionOverrides};
7use mcp_utils::client::McpConfig;
8use std::collections::HashSet;
9use std::path::{Path, PathBuf};
10use utils::variables::VarError;
11
12/// A resolved catalog of agents from a project.
13///
14/// This type owns project-relative resolution context.
15#[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    /// Create an empty catalog for a project with no settings.
57    pub fn empty(project_root: PathBuf) -> Self {
58        Self::new(project_root, Vec::new(), None)
59    }
60
61    /// The project root directory.
62    pub fn project_root(&self) -> &Path {
63        &self.project_root
64    }
65
66    /// Get all agent specs in the catalog.
67    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    /// Get a specific agent by name.
83    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    /// Iterate over user-invocable agents.
91    pub fn user_invocable(&self) -> impl Iterator<Item = &AgentSpec> {
92        self.specs.iter().filter(|s| s.exposure.user_invocable)
93    }
94
95    /// Iterate over agent-invocable agents.
96    pub fn agent_invocable(&self) -> impl Iterator<Item = &AgentSpec> {
97        self.specs.iter().filter(|s| s.exposure.agent_invocable)
98    }
99
100    /// Resolve and return a named agent spec ready for runtime use.
101    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}