Skip to main content

aether_project/
agent_catalog.rs

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