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