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