Skip to main content

agy_bridge/config/
capabilities.rs

1//! Tool capability configuration.
2
3use serde::{Deserialize, Serialize};
4
5use super::DEFAULT_IMAGE_GENERATION_MODEL;
6
7#[non_exhaustive]
8#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum BuiltinTools {
11    ListDir,
12    SearchDir,
13    FindFile,
14    ViewFile,
15    CreateFile,
16    EditFile,
17    RunCommand,
18    AskQuestion,
19    StartSubagent,
20    GenerateImage,
21    Finish,
22}
23
24impl BuiltinTools {
25    #[must_use]
26    /// Returns tools that only read (no writes, no command execution).
27    pub const fn read_only() -> &'static [Self] {
28        &[
29            Self::ListDir,
30            Self::SearchDir,
31            Self::FindFile,
32            Self::ViewFile,
33            Self::Finish,
34        ]
35    }
36
37    /// Returns tools that cannot delete content (all except `RunCommand`).
38    #[must_use]
39    pub const fn nondestructive() -> &'static [Self] {
40        &[
41            Self::ListDir,
42            Self::SearchDir,
43            Self::FindFile,
44            Self::ViewFile,
45            Self::CreateFile,
46            Self::EditFile,
47            Self::AskQuestion,
48            Self::StartSubagent,
49            Self::GenerateImage,
50            Self::Finish,
51        ]
52    }
53
54    /// Returns all builtin tools.
55    #[must_use]
56    pub const fn all_tools() -> &'static [Self] {
57        &[
58            Self::ListDir,
59            Self::SearchDir,
60            Self::FindFile,
61            Self::ViewFile,
62            Self::CreateFile,
63            Self::EditFile,
64            Self::RunCommand,
65            Self::AskQuestion,
66            Self::StartSubagent,
67            Self::GenerateImage,
68            Self::Finish,
69        ]
70    }
71
72    /// Returns tools that perform file read/write/create operations.
73    ///
74    /// These tools accept a file path argument and can be scoped to specific
75    /// workspace directories via `policy::workspace_only()`.
76    #[must_use]
77    pub const fn file_tools() -> &'static [Self] {
78        &[Self::ViewFile, Self::CreateFile, Self::EditFile]
79    }
80
81    /// Returns an empty tool list (no builtin tools).
82    #[must_use]
83    pub const fn none() -> &'static [Self] {
84        &[]
85    }
86
87    #[must_use]
88    /// Returns the Python SDK tool name string (e.g. `"list_directory"`).
89    pub const fn as_sdk_name(&self) -> &'static str {
90        match self {
91            Self::ListDir => "list_directory",
92            Self::SearchDir => "search_directory",
93            Self::FindFile => "find_file",
94            Self::ViewFile => "view_file",
95            Self::CreateFile => "create_file",
96            Self::EditFile => "edit_file",
97            Self::RunCommand => "run_command",
98            Self::AskQuestion => "ask_question",
99            Self::StartSubagent => "start_subagent",
100            Self::GenerateImage => "generate_image",
101            Self::Finish => "finish",
102        }
103    }
104}
105
106impl std::fmt::Display for BuiltinTools {
107    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108        f.write_str(self.as_sdk_name())
109    }
110}
111
112/// Agent capability toggles: tool allowlists, subagent support, and compaction.
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct CapabilitiesConfig {
115    /// Whether this agent can spawn subagents.
116    #[serde(default = "super::default_true")]
117    pub enable_subagents: bool,
118    /// If set, only these built-in tools are available (allowlist).
119    #[serde(default)]
120    pub enabled_tools: Option<Vec<BuiltinTools>>,
121    /// If set, these built-in tools are removed (denylist).
122    #[serde(default)]
123    pub disabled_tools: Option<Vec<BuiltinTools>>,
124    /// Token threshold that triggers conversation compaction.
125    pub compaction_threshold: Option<usize>,
126    /// The model to use for image generation.
127    ///
128    /// This setting is a shorthand for `GeminiConfig.models.image_generation.name`.
129    /// If both are specified, the value in [`GeminiConfig`](super::GeminiConfig) takes precedence and
130    /// this field is ignored.
131    #[serde(default = "super::default_image_model")]
132    pub image_model: String,
133    /// Optional JSON schema string for the finish tool's structured output.
134    #[serde(default)]
135    pub finish_tool_schema_json: Option<String>,
136}
137
138impl CapabilitiesConfig {
139    /// Create a capabilities config with only the specified tools enabled.
140    ///
141    /// Subagent support is enabled by default.
142    #[must_use]
143    pub fn with_tools(tools: Vec<BuiltinTools>) -> Self {
144        Self {
145            enabled_tools: Some(tools),
146            ..Self::default()
147        }
148    }
149
150    /// Create a capabilities config with all tools and subagent support.
151    #[must_use]
152    pub fn full() -> Self {
153        Self::default()
154    }
155
156    /// Create a capabilities config for read-only agents with subagent support.
157    #[must_use]
158    pub fn read_only() -> Self {
159        Self {
160            enabled_tools: Some(BuiltinTools::read_only().to_vec()),
161            ..Self::default()
162        }
163    }
164
165    /// Create a capabilities config with no builtin tools — only custom tools.
166    ///
167    /// Subagent support is still enabled.
168    #[must_use]
169    pub fn custom_tools_only() -> Self {
170        Self {
171            enabled_tools: Some(vec![]),
172            ..Self::default()
173        }
174    }
175
176    /// # Errors
177    ///
178    /// Returns an error if `enabled_tools` and `disabled_tools` are both provided.
179    pub const fn validate(&self) -> Result<(), &'static str> {
180        if self.enabled_tools.is_some() && self.disabled_tools.is_some() {
181            return Err("enabled_tools and disabled_tools are mutually exclusive");
182        }
183        Ok(())
184    }
185}
186
187impl Default for CapabilitiesConfig {
188    fn default() -> Self {
189        Self {
190            enable_subagents: true,
191            enabled_tools: None,
192            disabled_tools: None,
193            compaction_threshold: None,
194            image_model: DEFAULT_IMAGE_GENERATION_MODEL.to_owned(),
195            finish_tool_schema_json: None,
196        }
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use pyo3::types::PyAnyMethods;
203
204    use super::*;
205
206    #[test]
207    fn test_builtin_tools() {
208        let read_only = BuiltinTools::read_only();
209        assert_eq!(read_only.len(), 5);
210        assert!(read_only.contains(&BuiltinTools::ListDir));
211        assert!(read_only.contains(&BuiltinTools::Finish));
212        assert!(!read_only.contains(&BuiltinTools::CreateFile));
213
214        let all = BuiltinTools::all_tools();
215        assert_eq!(all.len(), 11);
216        assert!(all.contains(&BuiltinTools::CreateFile));
217        assert!(all.contains(&BuiltinTools::Finish));
218
219        assert_eq!(BuiltinTools::ListDir.as_sdk_name(), "list_directory");
220    }
221
222    #[test]
223    fn test_capabilities_validation() {
224        let mut caps = CapabilitiesConfig {
225            enable_subagents: true,
226            enabled_tools: Some(vec![BuiltinTools::ListDir]),
227            ..CapabilitiesConfig::default()
228        };
229        assert!(caps.validate().is_ok());
230
231        caps.disabled_tools = Some(vec![BuiltinTools::SearchDir]);
232        assert!(caps.validate().is_err());
233    }
234
235    #[test]
236
237    fn builtin_tools_serde_roundtrip_all_variants() {
238        let all = BuiltinTools::all_tools();
239        for tool in all {
240            let json = serde_json::to_string(tool).unwrap();
241            let parsed: BuiltinTools = serde_json::from_str(&json).unwrap();
242            assert_eq!(&parsed, tool, "Failed roundtrip for {tool:?}");
243        }
244    }
245
246    #[test]
247    fn builtin_tools_python_str_covers_all_variants() {
248        let expected = [
249            (BuiltinTools::ListDir, "list_directory"),
250            (BuiltinTools::SearchDir, "search_directory"),
251            (BuiltinTools::FindFile, "find_file"),
252            (BuiltinTools::ViewFile, "view_file"),
253            (BuiltinTools::CreateFile, "create_file"),
254            (BuiltinTools::EditFile, "edit_file"),
255            (BuiltinTools::RunCommand, "run_command"),
256            (BuiltinTools::AskQuestion, "ask_question"),
257            (BuiltinTools::StartSubagent, "start_subagent"),
258            (BuiltinTools::GenerateImage, "generate_image"),
259            (BuiltinTools::Finish, "finish"),
260        ];
261        for (variant, py_str) in expected {
262            assert_eq!(
263                variant.as_sdk_name(),
264                py_str,
265                "Python str mismatch for {variant:?}"
266            );
267        }
268    }
269
270    #[test]
271    fn builtin_tools_read_only_is_subset_of_all() {
272        let all = BuiltinTools::all_tools();
273        let read_only = BuiltinTools::read_only();
274        for tool in read_only {
275            assert!(
276                all.contains(tool),
277                "{tool:?} in read_only but not in all_tools"
278            );
279        }
280    }
281
282    #[test]
283    fn builtin_tools_read_only_excludes_write_tools() {
284        let read_only = BuiltinTools::read_only();
285        assert!(!read_only.contains(&BuiltinTools::CreateFile));
286        assert!(!read_only.contains(&BuiltinTools::EditFile));
287        assert!(!read_only.contains(&BuiltinTools::RunCommand));
288        assert!(!read_only.contains(&BuiltinTools::StartSubagent));
289        assert!(!read_only.contains(&BuiltinTools::GenerateImage));
290        assert!(!read_only.contains(&BuiltinTools::AskQuestion));
291    }
292
293    #[test]
294    fn capabilities_config_both_none_is_valid() {
295        let caps = CapabilitiesConfig::default();
296        assert!(caps.validate().is_ok());
297    }
298
299    #[test]
300    fn capabilities_config_only_disabled_is_valid() {
301        let caps = CapabilitiesConfig {
302            disabled_tools: Some(vec![BuiltinTools::RunCommand]),
303            compaction_threshold: Some(2000),
304            ..CapabilitiesConfig::default()
305        };
306        assert!(caps.validate().is_ok());
307    }
308
309    #[test]
310    fn capabilities_config_serde_roundtrip() {
311        let caps = CapabilitiesConfig {
312            enable_subagents: true,
313            enabled_tools: Some(vec![BuiltinTools::ViewFile, BuiltinTools::ListDir]),
314            compaction_threshold: Some(8000),
315            ..CapabilitiesConfig::default()
316        };
317        let json = serde_json::to_string(&caps).unwrap();
318        let parsed: CapabilitiesConfig = serde_json::from_str(&json).unwrap();
319        assert!(parsed.enable_subagents);
320        assert_eq!(parsed.enabled_tools.as_ref().unwrap().len(), 2);
321        assert_eq!(parsed.compaction_threshold, Some(8000));
322    }
323
324    #[test]
325    fn builtin_tools_snake_case_serde() {
326        // Verify that serde serializes with snake_case as specified by the attribute.
327        let tool = BuiltinTools::StartSubagent;
328        let json = serde_json::to_string(&tool).unwrap();
329        assert_eq!(json, "\"start_subagent\"");
330
331        let tool = BuiltinTools::GenerateImage;
332        let json = serde_json::to_string(&tool).unwrap();
333        assert_eq!(json, "\"generate_image\"");
334    }
335
336    #[test]
337    fn capabilities_config_empty_enabled_list_vs_none() {
338        // An explicitly empty enabled_tools list means "no tools enabled"
339        // whereas None means "use default set".
340        let caps_empty = CapabilitiesConfig {
341            enabled_tools: Some(vec![]),
342            ..CapabilitiesConfig::default()
343        };
344        assert!(caps_empty.validate().is_ok());
345        assert!(caps_empty.enabled_tools.as_ref().unwrap().is_empty());
346
347        let caps_none = CapabilitiesConfig::default();
348        assert!(caps_none.enabled_tools.is_none());
349    }
350
351    #[test]
352    fn capabilities_default_enables_subagents() {
353        // Matches the Python SDK default: enable_subagents=True
354        let caps = CapabilitiesConfig::default();
355        assert!(
356            caps.enable_subagents,
357            "enable_subagents should default to true, matching the SDK"
358        );
359    }
360
361    #[test]
362    fn capabilities_serde_missing_enable_subagents_defaults_true() {
363        // When enable_subagents is absent from JSON, it should default to true.
364        let json = r#"{"enabled_tools": ["view_file"]}"#;
365        let caps: CapabilitiesConfig = serde_json::from_str(json).unwrap();
366        assert!(
367            caps.enable_subagents,
368            "Missing enable_subagents in JSON should deserialize to true"
369        );
370    }
371
372    #[test]
373    fn capabilities_serde_explicit_false_is_respected() {
374        let json = r#"{"enable_subagents": false}"#;
375        let caps: CapabilitiesConfig = serde_json::from_str(json).unwrap();
376        assert!(!caps.enable_subagents, "Explicit false should be preserved");
377    }
378
379    #[test]
380    fn capabilities_with_tools_enables_subagents() {
381        let caps = CapabilitiesConfig::with_tools(vec![
382            BuiltinTools::ViewFile,
383            BuiltinTools::StartSubagent,
384        ]);
385        assert!(caps.enable_subagents);
386        assert_eq!(caps.enabled_tools.as_ref().unwrap().len(), 2);
387    }
388
389    #[test]
390    fn capabilities_full_enables_subagents() {
391        let caps = CapabilitiesConfig::full();
392        assert!(caps.enable_subagents);
393        assert!(caps.enabled_tools.is_none()); // None = SDK defaults (all tools)
394    }
395
396    #[test]
397    fn capabilities_read_only_enables_subagents_but_no_start_subagent() {
398        let caps = CapabilitiesConfig::read_only();
399        assert!(caps.enable_subagents);
400        let tools = caps.enabled_tools.as_ref().unwrap();
401        // read_only tools should NOT include StartSubagent
402        assert!(
403            !tools.contains(&BuiltinTools::StartSubagent),
404            "read_only should not include StartSubagent in enabled_tools"
405        );
406    }
407
408    #[test]
409    fn capabilities_custom_tools_only_enables_subagents() {
410        let caps = CapabilitiesConfig::custom_tools_only();
411        assert!(caps.enable_subagents);
412        assert!(caps.enabled_tools.as_ref().unwrap().is_empty());
413    }
414
415    #[test]
416    fn start_subagent_in_all_tools_and_nondestructive() {
417        let all = BuiltinTools::all_tools();
418        assert!(
419            all.contains(&BuiltinTools::StartSubagent),
420            "all_tools() must include StartSubagent"
421        );
422        let nondestructive = BuiltinTools::nondestructive();
423        assert!(
424            nondestructive.contains(&BuiltinTools::StartSubagent),
425            "nondestructive() must include StartSubagent"
426        );
427        let read_only = BuiltinTools::read_only();
428        assert!(
429            !read_only.contains(&BuiltinTools::StartSubagent),
430            "read_only() must NOT include StartSubagent"
431        );
432    }
433
434    /// Verify our `BuiltinTools` enum exactly matches the Python SDK's tool names.
435    #[test]
436    fn builtin_tools_match_python_sdk() {
437        pyo3::prepare_freethreaded_python();
438        pyo3::Python::with_gil(|py| {
439            crate::runtime::venv::configure_python_sys_path(py)
440                .unwrap_or_else(|e| panic!("Failed to configure python sys.path: {e}"));
441            let types_mod = py
442                .import_bound("google.antigravity.types")
443                .expect("Failed to import google.antigravity.types");
444            let bt = types_mod
445                .getattr("BuiltinTools")
446                .expect("Failed to get BuiltinTools");
447            // BuiltinTools is a (str, Enum) subclass — use `list(BuiltinTools)`
448            // to iterate members, then extract `.value` from each.
449            let builtins = py
450                .import_bound("builtins")
451                .expect("Failed to import builtins");
452            let members = builtins
453                .getattr("list")
454                .expect("Failed to get list")
455                .call1((bt,))
456                .expect("Failed to call list(BuiltinTools)");
457            let py_tools: Vec<String> = members
458                .iter()
459                .expect("Failed to iter members")
460                .map(|item| {
461                    item.and_then(|v| v.getattr("value"))
462                        .and_then(|v| v.extract::<String>())
463                })
464                .collect::<pyo3::PyResult<Vec<String>>>()
465                .expect("Failed to extract tool values");
466
467            let rust_tools: Vec<String> = BuiltinTools::all_tools()
468                .iter()
469                .map(|t| t.as_sdk_name().to_owned())
470                .collect();
471
472            assert_eq!(
473                rust_tools.len(),
474                py_tools.len(),
475                "Tool count mismatch: Rust has {}, Python has {}.\nRust: {rust_tools:?}\nPython: {py_tools:?}",
476                rust_tools.len(),
477                py_tools.len(),
478            );
479
480            for py_name in &py_tools {
481                assert!(
482                    rust_tools.contains(py_name),
483                    "Python SDK has tool '{py_name}' but Rust BuiltinTools does not"
484                );
485            }
486
487            for rust_name in &rust_tools {
488                assert!(
489                    py_tools.contains(rust_name),
490                    "Rust BuiltinTools has '{rust_name}' but Python SDK does not"
491                );
492            }
493        });
494    }
495
496    /// Verify the `BuiltinTools` enum maps correctly to the validate function.
497    #[test]
498    fn capabilities_validate_rejects_both_enabled_and_disabled() {
499        let caps = CapabilitiesConfig {
500            enabled_tools: Some(vec![BuiltinTools::ViewFile]),
501            disabled_tools: Some(vec![BuiltinTools::RunCommand]),
502            ..CapabilitiesConfig::default()
503        };
504        assert!(caps.validate().is_err());
505    }
506}