Skip to main content

aether_project/
catalog.rs

1//! Resolved agent catalog and runtime input types.
2
3use crate::error::SettingsError;
4use aether_core::agent_spec::{AgentSpec, McpJsonFileRef};
5use aether_core::core::Prompt;
6use llm::{LlmModel, ReasoningEffort};
7use std::path::{Path, PathBuf};
8
9/// A resolved catalog of agents from a project.
10///
11/// This type owns project-relative/inherited resolution context.
12#[derive(Debug, Clone)]
13pub struct AgentCatalog {
14    project_root: PathBuf,
15    inherited_prompts: Vec<Prompt>,
16    inherited_mcp_config_refs: Vec<McpJsonFileRef>,
17    specs: Vec<AgentSpec>,
18}
19
20impl AgentCatalog {
21    /// Create a catalog with the given resolved state.
22    pub(crate) fn new(
23        project_root: PathBuf,
24        inherited_prompts: Vec<Prompt>,
25        inherited_mcp_config_refs: Vec<McpJsonFileRef>,
26        specs: Vec<AgentSpec>,
27    ) -> Self {
28        Self { project_root, inherited_prompts, inherited_mcp_config_refs, specs }
29    }
30
31    /// Create an empty catalog for a project with no settings.
32    pub fn empty(project_root: PathBuf) -> Self {
33        Self::new(project_root, Vec::new(), Vec::new(), Vec::new())
34    }
35
36    /// The project root directory.
37    pub fn project_root(&self) -> &Path {
38        &self.project_root
39    }
40
41    /// Get all agent specs in the catalog.
42    pub fn all(&self) -> &[AgentSpec] {
43        &self.specs
44    }
45
46    /// Get a specific agent by name.
47    pub fn get(&self, name: &str) -> Result<&AgentSpec, SettingsError> {
48        self.specs
49            .iter()
50            .find(|spec| spec.name == name)
51            .ok_or_else(|| SettingsError::AgentNotFound { name: name.to_string() })
52    }
53
54    /// Iterate over user-invocable agents.
55    pub fn user_invocable(&self) -> impl Iterator<Item = &AgentSpec> {
56        self.specs.iter().filter(|s| s.exposure.user_invocable)
57    }
58
59    /// Iterate over agent-invocable agents.
60    pub fn agent_invocable(&self) -> impl Iterator<Item = &AgentSpec> {
61        self.specs.iter().filter(|s| s.exposure.agent_invocable)
62    }
63
64    /// Resolve and return a named agent spec ready for runtime use.
65    pub fn resolve(&self, name: &str, cwd: &Path) -> Result<AgentSpec, SettingsError> {
66        let mut spec = self.get(name)?.clone();
67        spec.resolve_mcp_config(&self.inherited_mcp_config_refs, cwd);
68        Ok(spec)
69    }
70
71    /// Resolve and return a default (no-mode) agent spec ready for runtime use.
72    pub fn resolve_default(
73        &self,
74        model: &LlmModel,
75        reasoning_effort: Option<ReasoningEffort>,
76        cwd: &Path,
77    ) -> AgentSpec {
78        let mut spec = AgentSpec::default_spec(model, reasoning_effort, self.inherited_prompts.clone());
79        spec.resolve_mcp_config(&self.inherited_mcp_config_refs, cwd);
80        spec
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87    use aether_core::agent_spec::{AgentSpecExposure, McpJsonFileRef, ToolFilter};
88    use std::fs;
89
90    fn create_temp_project() -> tempfile::TempDir {
91        tempfile::tempdir().unwrap()
92    }
93
94    fn write_file(dir: &Path, path: &str, content: &str) {
95        let full_path = dir.join(path);
96        if let Some(parent) = full_path.parent() {
97            fs::create_dir_all(parent).unwrap();
98        }
99        fs::write(full_path, content).unwrap();
100    }
101
102    fn make_spec(name: &str, exposure: AgentSpecExposure) -> AgentSpec {
103        AgentSpec {
104            name: name.to_string(),
105            description: format!("{name} agent"),
106            model: "anthropic:claude-sonnet-4-5".to_string(),
107            reasoning_effort: None,
108            prompts: vec![],
109            mcp_config_refs: Vec::new(),
110            exposure,
111            tools: ToolFilter::default(),
112        }
113    }
114
115    fn create_test_catalog(project_root: PathBuf) -> AgentCatalog {
116        let inherited_prompts = vec![Prompt::from_globs(vec!["BASE.md".to_string()], project_root.clone())];
117        let planner = AgentSpec {
118            name: "planner".to_string(),
119            description: "Planner agent".to_string(),
120            model: "anthropic:claude-sonnet-4-5".to_string(),
121            reasoning_effort: None,
122            prompts: vec![
123                Prompt::from_globs(vec!["BASE.md".to_string()], project_root.clone()),
124                Prompt::from_globs(vec!["AGENTS.md".to_string()], project_root.clone()),
125            ],
126            mcp_config_refs: Vec::new(),
127            exposure: AgentSpecExposure::both(),
128            tools: ToolFilter::default(),
129        };
130        AgentCatalog::new(project_root, inherited_prompts, Vec::new(), vec![planner])
131    }
132
133    #[test]
134    fn user_invocable_filters_correctly() {
135        let dir = create_temp_project();
136        let root = dir.path().to_path_buf();
137        let catalog = AgentCatalog::new(
138            root,
139            vec![],
140            Vec::new(),
141            vec![
142                make_spec("planner", AgentSpecExposure::both()),
143                make_spec("internal", AgentSpecExposure::agent_only()),
144            ],
145        );
146
147        let user_invocable: Vec<_> = catalog.user_invocable().collect();
148        assert_eq!(user_invocable.len(), 1);
149        assert_eq!(user_invocable[0].name, "planner");
150    }
151
152    #[test]
153    fn agent_invocable_filters_correctly() {
154        let dir = create_temp_project();
155        let root = dir.path().to_path_buf();
156        let catalog = AgentCatalog::new(
157            root,
158            vec![],
159            Vec::new(),
160            vec![
161                make_spec("planner", AgentSpecExposure::both()),
162                make_spec("user-only", AgentSpecExposure::user_only()),
163            ],
164        );
165
166        let agent_invocable: Vec<_> = catalog.agent_invocable().collect();
167        assert_eq!(agent_invocable.len(), 1);
168        assert_eq!(agent_invocable[0].name, "planner");
169    }
170
171    #[test]
172    fn get_returns_error_for_missing_agent() {
173        let dir = create_temp_project();
174        let catalog = create_test_catalog(dir.path().to_path_buf());
175        let result = catalog.get("nonexistent");
176        assert!(matches!(result, Err(SettingsError::AgentNotFound { .. })));
177    }
178
179    #[test]
180    fn resolve_missing_agent_returns_error() {
181        let dir = create_temp_project();
182        let catalog = create_test_catalog(dir.path().to_path_buf());
183        let result = catalog.resolve("missing", dir.path());
184        assert!(matches!(result, Err(SettingsError::AgentNotFound { .. })));
185    }
186
187    #[test]
188    fn resolve_selects_agent_mcp_over_inherited() {
189        let dir = create_temp_project();
190        write_file(dir.path(), "agent-mcp.json", "{}");
191        write_file(dir.path(), "inherited-mcp.json", "{}");
192
193        let mut planner = make_spec("planner", AgentSpecExposure::both());
194        planner.mcp_config_refs = vec![McpJsonFileRef::direct(dir.path().join("agent-mcp.json"))];
195
196        let catalog = AgentCatalog::new(
197            dir.path().to_path_buf(),
198            vec![],
199            vec![McpJsonFileRef::direct(dir.path().join("inherited-mcp.json"))],
200            vec![planner],
201        );
202
203        let spec = catalog.resolve("planner", dir.path()).unwrap();
204        assert_eq!(spec.mcp_config_refs, vec![McpJsonFileRef::direct(dir.path().join("agent-mcp.json"))]);
205    }
206
207    #[test]
208    fn resolve_selects_inherited_mcp_over_cwd() {
209        let dir = create_temp_project();
210        write_file(dir.path(), "inherited-mcp.json", "{}");
211        write_file(dir.path(), "mcp.json", "{}");
212
213        let catalog = AgentCatalog::new(
214            dir.path().to_path_buf(),
215            vec![],
216            vec![McpJsonFileRef::direct(dir.path().join("inherited-mcp.json"))],
217            vec![make_spec("planner", AgentSpecExposure::both())],
218        );
219
220        let spec = catalog.resolve("planner", dir.path()).unwrap();
221        assert_eq!(spec.mcp_config_refs, vec![McpJsonFileRef::direct(dir.path().join("inherited-mcp.json"))]);
222    }
223
224    #[test]
225    fn resolve_falls_back_to_cwd_mcp() {
226        let dir = create_temp_project();
227        write_file(dir.path(), "mcp.json", "{}");
228
229        let catalog = AgentCatalog::new(
230            dir.path().to_path_buf(),
231            vec![],
232            Vec::new(),
233            vec![make_spec("planner", AgentSpecExposure::both())],
234        );
235
236        let spec = catalog.resolve("planner", dir.path()).unwrap();
237        assert_eq!(spec.mcp_config_refs, vec![McpJsonFileRef::direct(dir.path().join("mcp.json"))]);
238    }
239
240    #[test]
241    fn resolve_no_mcp_config_is_valid() {
242        let dir = create_temp_project();
243        let catalog = AgentCatalog::new(
244            dir.path().to_path_buf(),
245            vec![],
246            Vec::new(),
247            vec![make_spec("planner", AgentSpecExposure::both())],
248        );
249
250        let spec = catalog.resolve("planner", dir.path()).unwrap();
251        assert!(spec.mcp_config_refs.is_empty());
252    }
253
254    #[test]
255    fn resolve_default_includes_inherited_prompts() {
256        let dir = create_temp_project();
257        let catalog = create_test_catalog(dir.path().to_path_buf());
258
259        let model: llm::LlmModel = "anthropic:claude-sonnet-4-5".parse().unwrap();
260        let spec = catalog.resolve_default(&model, None, dir.path());
261
262        assert_eq!(spec.name, "__default__");
263        assert_eq!(spec.model, model.to_string());
264        assert_eq!(spec.prompts.len(), 1);
265        assert!(spec.mcp_config_refs.is_empty());
266    }
267}