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