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