Skip to main content

bamboo_tools/
executor.rs

1use std::sync::Arc;
2
3use async_trait::async_trait;
4use bamboo_agent_core::{
5    normalize_tool_name, parse_tool_args_best_effort, Tool, ToolCall, ToolError,
6    ToolExecutionContext, ToolExecutor, ToolResult, ToolSchema,
7};
8use bamboo_domain::tool_names::{normalize_builtin_alias, resolve_alias};
9
10use crate::guide::{context::GuideBuildContext, EnhancedPromptBuilder, ToolGuide};
11use crate::permission::{check_permissions, PermissionChecker, PermissionError};
12use crate::tools::{
13    BashOutputTool, BashTool, ConclusionWithOptionsTool, EditTool, EnterPlanModeTool,
14    ExitPlanModeTool, GetFileInfoTool, GlobTool, GrepTool, JsReplTool, KillShellTool,
15    NotebookEditTool, ReadTool, RequestPermissionsTool, SessionNoteTool, SleepTool, TaskTool,
16    ToolRegistry, WebFetchTool, WebSearchTool, WorkspaceTool, WriteTool,
17};
18use bamboo_infrastructure::Config;
19use tokio::sync::RwLock;
20
21fn preview_for_log(value: &str, max_chars: usize) -> String {
22    let mut iter = value.chars();
23    let mut preview = String::new();
24    for _ in 0..max_chars {
25        match iter.next() {
26            Some(ch) => preview.push(ch),
27            None => break,
28        }
29    }
30    if iter.next().is_some() {
31        preview.push_str("...");
32    }
33    preview.replace('\n', "\\n").replace('\r', "\\r")
34}
35
36fn copy_legacy_arg_if_missing(
37    args: &mut serde_json::Map<String, serde_json::Value>,
38    from: &str,
39    to: &str,
40) {
41    if args.contains_key(to) {
42        return;
43    }
44    if let Some(value) = args.get(from).cloned() {
45        args.insert(to.to_string(), value);
46    }
47}
48
49fn normalize_legacy_builtin_args(
50    raw_tool_name: &str,
51    args: &mut serde_json::Map<String, serde_json::Value>,
52) {
53    match raw_tool_name {
54        "read_file" | "write_file" | "Read" | "Write" | "apply_patch" => {
55            copy_legacy_arg_if_missing(args, "path", "file_path");
56        }
57        "execute_command" | "Bash" => {
58            copy_legacy_arg_if_missing(args, "cmd", "command");
59        }
60        "list_directory" | "Glob" => {
61            let should_default_pattern = raw_tool_name == "list_directory"
62                || args.contains_key("path")
63                || args.contains_key("recursive");
64            if should_default_pattern && !args.contains_key("pattern") {
65                let recursive = args
66                    .get("recursive")
67                    .and_then(serde_json::Value::as_bool)
68                    .unwrap_or(false);
69                let pattern = if recursive { "**/*" } else { "*" };
70                args.insert(
71                    "pattern".to_string(),
72                    serde_json::Value::String(pattern.to_string()),
73                );
74            }
75            args.remove("recursive");
76        }
77        _ => {}
78    }
79}
80
81fn resolve_registered_tool_name(registry: &ToolRegistry, raw_tool_name: &str) -> String {
82    if registry.get(raw_tool_name).is_some() {
83        return raw_tool_name.to_string();
84    }
85
86    let aliased = normalize_builtin_alias(raw_tool_name);
87    if registry.get(aliased).is_some() {
88        return aliased.to_string();
89    }
90
91    resolve_alias(aliased).unwrap_or(aliased).to_string()
92}
93
94/// Built-in tool executor that uses ToolRegistry for dynamic dispatch
95pub struct BuiltinToolExecutor {
96    registry: ToolRegistry,
97    permission_checker: Option<Arc<dyn PermissionChecker>>,
98}
99
100impl BuiltinToolExecutor {
101    /// Creates a new executor with all built-in tools registered
102    pub fn new() -> Self {
103        let registry = ToolRegistry::new();
104        Self::register_builtin_tools(&registry, None);
105        Self {
106            registry,
107            permission_checker: None,
108        }
109    }
110
111    /// Creates a new executor with a permission checker
112    pub fn new_with_permissions(permission_checker: Arc<dyn PermissionChecker>) -> Self {
113        let registry = ToolRegistry::new();
114        Self::register_builtin_tools(&registry, None);
115        Self {
116            registry,
117            permission_checker: Some(permission_checker),
118        }
119    }
120
121    /// Creates a new executor that can read the shared, hot-reloadable config.
122    ///
123    /// Use this when running inside the Bamboo server so tools (notably
124    /// `http_request`) honor proxy settings from `config.json`.
125    pub fn new_with_config(config: Arc<RwLock<Config>>) -> Self {
126        let registry = ToolRegistry::new();
127        Self::register_builtin_tools(&registry, Some(config));
128        Self {
129            registry,
130            permission_checker: None,
131        }
132    }
133
134    /// Creates a new executor with both shared config and a permission checker.
135    pub fn new_with_config_and_permissions(
136        config: Arc<RwLock<Config>>,
137        permission_checker: Arc<dyn PermissionChecker>,
138    ) -> Self {
139        let registry = ToolRegistry::new();
140        Self::register_builtin_tools(&registry, Some(config));
141        Self {
142            registry,
143            permission_checker: Some(permission_checker),
144        }
145    }
146
147    /// Creates a new executor from an existing registry
148    pub fn with_registry(registry: ToolRegistry) -> Self {
149        Self {
150            registry,
151            permission_checker: None,
152        }
153    }
154
155    /// Returns a reference to the internal registry
156    pub fn registry(&self) -> &ToolRegistry {
157        &self.registry
158    }
159
160    /// Registers all built-in tools to the given registry
161    fn register_builtin_tools(registry: &ToolRegistry, config: Option<Arc<RwLock<Config>>>) {
162        let _ = config;
163        // NOTE: apply_patch is now an alias for Edit – no separate registration.
164        let _ = registry.register(ConclusionWithOptionsTool::new());
165        let _ = registry.register(BashTool::new());
166        let _ = registry.register(BashOutputTool::new());
167        let _ = registry.register(EditTool::new());
168        let _ = registry.register(EnterPlanModeTool::new());
169        let _ = registry.register(ExitPlanModeTool::new());
170        // NOTE: FileExists is now an alias for GetFileInfo – no separate registration.
171        let _ = registry.register(GetFileInfoTool::new());
172        let _ = registry.register(GlobTool::new());
173        let _ = registry.register(GrepTool::new());
174        let _ = registry.register(JsReplTool::new());
175        let _ = registry.register(KillShellTool::new());
176        let _ = registry.register(SessionNoteTool::new());
177        let _ = registry.register(NotebookEditTool::new());
178        let _ = registry.register(ReadTool::new());
179        let _ = registry.register(RequestPermissionsTool::new());
180        let _ = registry.register(SleepTool::new());
181        let _ = registry.register(TaskTool::new());
182        let _ = registry.register(WebFetchTool::new());
183        let _ = registry.register(WebSearchTool::new());
184        // NOTE: GetCurrentDir + SetWorkspace are now aliases for Workspace.
185        let _ = registry.register(WorkspaceTool::new());
186        let _ = registry.register(WriteTool::new());
187    }
188
189    /// Returns all built-in tool schemas
190    pub fn tool_schemas() -> Vec<ToolSchema> {
191        let registry = ToolRegistry::new();
192        Self::register_builtin_tools(&registry, None);
193        registry.list_tools()
194    }
195
196    /// Registers a custom tool to this executor
197    pub fn register_tool<T: Tool + 'static>(&self, tool: T) -> Result<(), ToolError> {
198        self.registry
199            .register(tool)
200            .map_err(|e| ToolError::Execution(e.to_string()))
201    }
202
203    /// Register a tool with its guide
204    pub fn register_tool_with_guide<T, G>(&self, tool: T, guide: G) -> Result<(), ToolError>
205    where
206        T: Tool + 'static,
207        G: ToolGuide + 'static,
208    {
209        self.registry
210            .register_with_guide(tool, guide)
211            .map_err(|e| ToolError::Execution(e.to_string()))
212    }
213
214    /// Get guide for a tool
215    pub fn get_guide(&self, tool_name: &str) -> Option<Arc<dyn ToolGuide>> {
216        self.registry.get_guide(tool_name)
217    }
218
219    /// Build enhanced prompt for all registered tools
220    pub fn build_enhanced_prompt(&self, context: GuideBuildContext) -> String {
221        EnhancedPromptBuilder::build(Some(&self.registry), &self.registry.list_tools(), &context)
222    }
223}
224
225fn permission_error_to_tool_error(error: PermissionError) -> ToolError {
226    match error {
227        PermissionError::CheckFailed(_) => ToolError::InvalidArguments(error.to_string()),
228        _ => ToolError::Execution(error.to_string()),
229    }
230}
231
232impl Default for BuiltinToolExecutor {
233    fn default() -> Self {
234        Self::new()
235    }
236}
237
238#[async_trait]
239impl ToolExecutor for BuiltinToolExecutor {
240    async fn execute(&self, call: &ToolCall) -> Result<ToolResult, ToolError> {
241        self.execute_with_context(call, ToolExecutionContext::none(&call.id))
242            .await
243    }
244
245    async fn execute_with_context(
246        &self,
247        call: &ToolCall,
248        ctx: ToolExecutionContext<'_>,
249    ) -> Result<ToolResult, ToolError> {
250        let args_raw = call.function.arguments.trim();
251        let (mut args, parse_warning) = parse_tool_args_best_effort(&call.function.arguments);
252        if let Some(warning) = parse_warning {
253            tracing::warn!(
254                "Builtin tool argument parsing fallback applied: session_id={:?}, tool_call_id={}, tool_name={}, args_len={}, args_preview=\"{}\", warning={}",
255                ctx.session_id,
256                call.id,
257                call.function.name,
258                args_raw.len(),
259                preview_for_log(args_raw, 180),
260                warning
261            );
262        }
263
264        let raw_tool_name = normalize_tool_name(&call.function.name);
265        if let Some(args_obj) = args.as_object_mut() {
266            normalize_legacy_builtin_args(raw_tool_name, args_obj);
267        }
268
269        let tool_name = resolve_registered_tool_name(&self.registry, raw_tool_name);
270
271        // Look up the tool in the registry
272        let tool = self
273            .registry
274            .get(&tool_name)
275            .ok_or_else(|| ToolError::NotFound(format!("Tool '{}' not found", tool_name)))?;
276
277        if let Some(permission_checker) = &self.permission_checker {
278            if let Some(contexts) =
279                check_permissions(&tool_name, &args).map_err(permission_error_to_tool_error)?
280            {
281                for context in contexts {
282                    let resource = context.resource.clone();
283                    let allowed = permission_checker
284                        .check_or_request(context)
285                        .await
286                        .map_err(permission_error_to_tool_error)?;
287                    if !allowed {
288                        return Err(ToolError::Execution(format!(
289                            "Permission denied for: {}",
290                            resource
291                        )));
292                    }
293                }
294            }
295        }
296
297        tool.execute_with_context(args, ctx).await
298    }
299
300    fn list_tools(&self) -> Vec<ToolSchema> {
301        self.registry.list_tools()
302    }
303
304    fn tool_mutability(&self, tool_name: &str) -> crate::ToolMutability {
305        self.registry
306            .get(tool_name)
307            .map(|tool| tool.mutability())
308            .unwrap_or_else(|| crate::classify_tool(tool_name))
309    }
310
311    fn call_mutability(&self, call: &ToolCall) -> crate::ToolMutability {
312        let canonical = resolve_registered_tool_name(&self.registry, call.function.name.trim());
313        let args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
314        self.registry
315            .get(&canonical)
316            .map(|tool| tool.call_mutability(&args))
317            .unwrap_or_else(|| self.tool_mutability(&canonical))
318    }
319
320    fn tool_concurrency_safe(&self, tool_name: &str) -> bool {
321        let canonical = resolve_registered_tool_name(&self.registry, tool_name);
322        self.registry
323            .get(&canonical)
324            .map(|tool| tool.concurrency_safe())
325            .unwrap_or_else(|| self.tool_mutability(&canonical) == crate::ToolMutability::ReadOnly)
326    }
327
328    fn call_concurrency_safe(&self, call: &ToolCall) -> bool {
329        let canonical = resolve_registered_tool_name(&self.registry, call.function.name.trim());
330        let args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
331        self.registry
332            .get(&canonical)
333            .map(|tool| tool.call_concurrency_safe(&args))
334            .unwrap_or_else(|| self.tool_concurrency_safe(&canonical))
335    }
336}
337
338/// Builder for constructing a BuiltinToolExecutor with custom tool configurations
339pub struct BuiltinToolExecutorBuilder {
340    registry: ToolRegistry,
341    permission_checker: Option<Arc<dyn PermissionChecker>>,
342}
343
344impl BuiltinToolExecutorBuilder {
345    /// Creates a new builder with no tools registered
346    pub fn new() -> Self {
347        Self {
348            registry: ToolRegistry::new(),
349            permission_checker: None,
350        }
351    }
352
353    /// Registers all default built-in tools
354    pub fn with_default_tools(self) -> Self {
355        BuiltinToolExecutor::register_builtin_tools(&self.registry, None);
356        self
357    }
358
359    /// Registers a specific filesystem tool by name
360    pub fn with_filesystem_tool(self, name: &str) -> Result<Self, ToolError> {
361        match name {
362            "Read" => self.registry.register(ReadTool::new()),
363            "Write" => self.registry.register(WriteTool::new()),
364            // apply_patch is now an alias for Edit
365            "Edit" | "apply_patch" => self.registry.register(EditTool::new()),
366            "NotebookEdit" => self.registry.register(NotebookEditTool::new()),
367            _ => return Err(ToolError::NotFound(format!("Unknown tool: {}", name))),
368        }
369        .map_err(|e| ToolError::Execution(e.to_string()))?;
370        Ok(self)
371    }
372
373    /// Registers a specific command tool by name
374    pub fn with_command_tool(self, name: &str) -> Result<Self, ToolError> {
375        match name {
376            "Bash" => self.registry.register(BashTool::new()),
377            "BashOutput" => self.registry.register(BashOutputTool::new()),
378            "KillShell" => self.registry.register(KillShellTool::new()),
379            "Task" => self.registry.register(TaskTool::new()),
380            _ => return Err(ToolError::NotFound(format!("Unknown tool: {}", name))),
381        }
382        .map_err(|e| ToolError::Execution(e.to_string()))?;
383        Ok(self)
384    }
385
386    /// Registers a custom tool
387    pub fn with_tool<T: Tool + 'static>(self, tool: T) -> Result<Self, ToolError> {
388        self.registry
389            .register(tool)
390            .map_err(|e| ToolError::Execution(e.to_string()))?;
391        Ok(self)
392    }
393
394    /// Sets a permission checker for this executor
395    pub fn with_permission_checker(mut self, checker: Arc<dyn PermissionChecker>) -> Self {
396        self.permission_checker = Some(checker);
397        self
398    }
399
400    /// Builds the executor
401    pub fn build(self) -> BuiltinToolExecutor {
402        BuiltinToolExecutor {
403            registry: self.registry,
404            permission_checker: self.permission_checker,
405        }
406    }
407}
408
409impl Default for BuiltinToolExecutorBuilder {
410    fn default() -> Self {
411        Self::new()
412    }
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418    use bamboo_agent_core::AgentEvent;
419    use bamboo_agent_core::FunctionCall;
420    use bamboo_agent_core::ToolExecutionContext;
421    use bamboo_domain::tool_names::{normalize_tool_ref, BUILTIN_TOOL_NAMES};
422    use serde_json::json;
423    use std::sync::Arc;
424    use tokio::fs;
425    use tokio::sync::mpsc;
426
427    use crate::tools::WriteTool;
428
429    fn make_tool_call(name: &str, args: serde_json::Value) -> ToolCall {
430        ToolCall {
431            id: "call_1".to_string(),
432            tool_type: "function".to_string(),
433            function: FunctionCall {
434                name: name.to_string(),
435                arguments: args.to_string(),
436            },
437        }
438    }
439
440    fn make_tool_call_with_raw_args(name: &str, raw_args: &str) -> ToolCall {
441        ToolCall {
442            id: "call_1".to_string(),
443            tool_type: "function".to_string(),
444            function: FunctionCall {
445                name: name.to_string(),
446                arguments: raw_args.to_string(),
447            },
448        }
449    }
450
451    fn make_executor(
452        permission_checker: Option<Arc<dyn PermissionChecker>>,
453    ) -> BuiltinToolExecutor {
454        let builder = BuiltinToolExecutorBuilder::new()
455            .with_tool(WriteTool::new())
456            .expect("register Write tool");
457
458        let builder = match permission_checker {
459            Some(checker) => builder.with_permission_checker(checker),
460            None => builder,
461        };
462
463        builder.build()
464    }
465
466    #[test]
467    fn test_normalize_tool_ref_accepts_claude_style_names() {
468        assert_eq!(
469            normalize_tool_ref("default::Bash"),
470            Some("Bash".to_string())
471        );
472    }
473
474    #[test]
475    fn test_normalize_tool_ref_accepts_legacy_camel_aliases() {
476        assert_eq!(
477            normalize_tool_ref("default::fileExists"),
478            Some("FileExists".to_string())
479        );
480        assert_eq!(
481            normalize_tool_ref("default::getCurrentDir"),
482            Some("GetCurrentDir".to_string())
483        );
484        assert_eq!(
485            normalize_tool_ref("default::getFileInfo"),
486            Some("GetFileInfo".to_string())
487        );
488        assert_eq!(
489            normalize_tool_ref("default::setWorkspace"),
490            Some("SetWorkspace".to_string())
491        );
492        assert_eq!(
493            normalize_tool_ref("default::sleep"),
494            Some("Sleep".to_string())
495        );
496    }
497
498    #[test]
499    fn test_normalize_tool_ref_accepts_legacy_snake_case_aliases() {
500        assert_eq!(
501            normalize_tool_ref("default::execute_command"),
502            Some("Bash".to_string())
503        );
504        assert_eq!(
505            normalize_tool_ref("default::file_exists"),
506            Some("FileExists".to_string())
507        );
508        assert_eq!(
509            normalize_tool_ref("default::get_current_dir"),
510            Some("GetCurrentDir".to_string())
511        );
512        assert_eq!(
513            normalize_tool_ref("default::get_file_info"),
514            Some("GetFileInfo".to_string())
515        );
516        assert_eq!(
517            normalize_tool_ref("default::list_directory"),
518            Some("Glob".to_string())
519        );
520        assert_eq!(
521            normalize_tool_ref("default::memory_note"),
522            Some("memory_note".to_string())
523        );
524        assert_eq!(
525            normalize_tool_ref("default::read_file"),
526            Some("Read".to_string())
527        );
528        assert_eq!(
529            normalize_tool_ref("default::set_workspace"),
530            Some("SetWorkspace".to_string())
531        );
532        assert_eq!(
533            normalize_tool_ref("default::write_file"),
534            Some("Write".to_string())
535        );
536    }
537
538    #[test]
539    fn test_normalize_tool_ref_accepts_spawn_task_aliases() {
540        for alias in [
541            "default::spawn_session",
542            "default::sub_session",
543            "default::sub_task",
544            "default::team_agent",
545            "default::child_session",
546        ] {
547            assert_eq!(normalize_tool_ref(alias), Some("SubSession".to_string()));
548        }
549    }
550
551    #[test]
552    fn test_normalize_tool_ref_accepts_server_overlay_tools() {
553        assert_eq!(normalize_tool_ref("compress_context"), None);
554        assert_eq!(
555            normalize_tool_ref("default::read_skill_resource"),
556            Some("read_skill_resource".to_string())
557        );
558    }
559
560    #[tokio::test]
561    async fn test_executor_accepts_legacy_read_file_path_argument() {
562        let dir = tempfile::tempdir().unwrap();
563        let file_path = dir.path().join("legacy-read.txt");
564        fs::write(&file_path, "legacy read content").await.unwrap();
565
566        let executor = BuiltinToolExecutor::new();
567        let call = make_tool_call("read_file", json!({"path": file_path}));
568
569        let result = executor.execute(&call).await.unwrap();
570        assert!(result.success);
571        assert!(result.result.contains("legacy read content"));
572    }
573
574    #[tokio::test]
575    async fn test_executor_accepts_legacy_list_directory_without_pattern() {
576        let dir = tempfile::tempdir().unwrap();
577        let file_path = dir.path().join("legacy-list.txt");
578        fs::write(&file_path, "legacy list content").await.unwrap();
579
580        let executor = BuiltinToolExecutor::new();
581        let call = make_tool_call("list_directory", json!({"path": dir.path()}));
582
583        let result = executor.execute(&call).await.unwrap();
584        assert!(result.success);
585        assert!(result.result.contains("legacy-list.txt"));
586    }
587
588    #[tokio::test]
589    async fn test_executor_accepts_canonical_read_with_path_argument() {
590        let dir = tempfile::tempdir().unwrap();
591        let file_path = dir.path().join("canonical-read.txt");
592        fs::write(&file_path, "canonical read content")
593            .await
594            .unwrap();
595
596        let executor = BuiltinToolExecutor::new();
597        let call = make_tool_call("Read", json!({"path": file_path}));
598
599        let result = executor.execute(&call).await.unwrap();
600        assert!(result.success);
601        assert!(result.result.contains("canonical read content"));
602    }
603
604    #[tokio::test]
605    async fn test_executor_accepts_canonical_glob_without_pattern_when_path_present() {
606        let dir = tempfile::tempdir().unwrap();
607        let file_path = dir.path().join("canonical-list.txt");
608        fs::write(&file_path, "canonical list content")
609            .await
610            .unwrap();
611
612        let executor = BuiltinToolExecutor::new();
613        let call = make_tool_call("Glob", json!({"path": dir.path()}));
614
615        let result = executor.execute(&call).await.unwrap();
616        assert!(result.success);
617        assert!(result.result.contains("canonical-list.txt"));
618    }
619
620    #[test]
621    fn test_executor_workspace_mutability_depends_on_path_argument() {
622        let executor = BuiltinToolExecutor::new();
623        let get_call = make_tool_call("Workspace", json!({}));
624        let set_call = make_tool_call("Workspace", json!({"path": "/tmp"}));
625
626        assert_eq!(
627            executor.call_mutability(&get_call),
628            crate::ToolMutability::ReadOnly
629        );
630        assert!(executor.call_concurrency_safe(&get_call));
631
632        assert_eq!(
633            executor.call_mutability(&set_call),
634            crate::ToolMutability::Mutating
635        );
636        assert!(!executor.call_concurrency_safe(&set_call));
637    }
638
639    #[tokio::test]
640    async fn test_executor_recovers_truncated_json_arguments() {
641        let dir = tempfile::tempdir().unwrap();
642        let path = dir.path().join("recovered-write.txt");
643
644        // Missing closing brace simulates EOF while parsing an object.
645        let malformed_args = format!(
646            r#"{{"file_path":"{}","content":"recovered content""#,
647            path.display()
648        );
649
650        let executor = BuiltinToolExecutor::new();
651        let call = make_tool_call_with_raw_args("Write", &malformed_args);
652
653        let result = executor
654            .execute(&call)
655            .await
656            .expect("truncated JSON should be auto-repaired");
657        assert!(result.success);
658
659        let written = fs::read_to_string(&path)
660            .await
661            .expect("file should be written");
662        assert_eq!(written, "recovered content");
663    }
664
665    #[test]
666    fn test_normalize_tool_ref_rejects_unknown_tool() {
667        assert_eq!(normalize_tool_ref("default::search"), None);
668    }
669
670    #[test]
671    fn test_executor_does_not_expose_legacy_tools() {
672        let executor = BuiltinToolExecutor::new();
673        let tool_names: Vec<String> = executor
674            .list_tools()
675            .into_iter()
676            .map(|schema| schema.function.name)
677            .collect();
678
679        for legacy in ["claude_code", "search_in_file", "search_in_project"] {
680            assert!(!tool_names.iter().any(|name| name == legacy));
681        }
682    }
683
684    #[test]
685    fn test_critical_tool_schemas_match_claude_shapes() {
686        let executor = BuiltinToolExecutor::new();
687        let tools = executor.list_tools();
688
689        let get_params = |name: &str| {
690            tools
691                .iter()
692                .find(|tool| tool.function.name == name)
693                .unwrap()
694                .function
695                .parameters
696                .clone()
697        };
698
699        let grep = get_params("Grep");
700        assert_eq!(grep["required"], json!(["pattern"]));
701        assert_eq!(
702            grep["properties"]["output_mode"]["enum"],
703            json!(["content", "files_with_matches", "count"])
704        );
705        assert!(grep["properties"]["-A"].is_object());
706        assert!(grep["properties"]["-B"].is_object());
707        assert!(grep["properties"]["-C"].is_object());
708        assert!(grep["properties"]["-n"].is_object());
709        assert!(grep["properties"]["-i"].is_object());
710
711        let edit = get_params("Edit");
712        assert_eq!(edit["required"], json!(["file_path"]));
713        assert_eq!(edit["properties"]["old_string"]["type"], "string");
714        assert_eq!(edit["properties"]["new_string"]["type"], "string");
715        assert_eq!(edit["properties"]["patch"]["type"], "string");
716        assert_eq!(edit["properties"]["replace_all"]["type"], "boolean");
717        assert!(edit.get("oneOf").is_none());
718
719        // apply_patch is now an alias for Edit – its schema is the Edit
720        // schema, so we just verify that Edit includes the patch property.
721        assert_eq!(edit["properties"]["patch"]["type"], "string");
722        assert_eq!(edit["properties"]["line_number"]["type"], "integer");
723
724        let bash = get_params("Bash");
725        assert_eq!(bash["required"], json!(["command"]));
726        assert_eq!(bash["properties"]["run_in_background"]["type"], "boolean");
727        assert_eq!(bash["properties"]["workdir"]["type"], "string");
728
729        let bash_output = get_params("BashOutput");
730        assert_eq!(bash_output["required"], json!(["bash_id"]));
731        assert_eq!(bash_output["properties"]["filter"]["type"], "string");
732    }
733
734    #[test]
735    fn test_tool_schemas_avoid_openai_forbidden_top_level_keywords() {
736        let executor = BuiltinToolExecutor::new();
737        let tools = executor.list_tools();
738        let forbidden = ["oneOf", "anyOf", "allOf", "not", "enum"];
739
740        for tool in tools {
741            let params = &tool.function.parameters;
742            assert_eq!(
743                params["type"], "object",
744                "tool '{}' parameters must be a top-level object schema",
745                tool.function.name
746            );
747            for key in forbidden {
748                assert!(
749                    params.get(key).is_none(),
750                    "tool '{}' parameters contains forbidden top-level keyword '{}'",
751                    tool.function.name,
752                    key
753                );
754            }
755        }
756    }
757
758    #[test]
759    fn test_executor_has_all_builtin_tools() {
760        let executor = BuiltinToolExecutor::new();
761        let tools = executor.list_tools();
762
763        assert_eq!(tools.len(), BUILTIN_TOOL_NAMES.len());
764
765        let tool_names: Vec<String> = tools.iter().map(|t| t.function.name.clone()).collect();
766        for tool_name in BUILTIN_TOOL_NAMES {
767            assert!(tool_names.contains(&tool_name.to_string()));
768        }
769    }
770
771    #[test]
772    fn test_executor_builds_enhanced_prompt() {
773        let executor = BuiltinToolExecutor::new();
774        let prompt = executor.build_enhanced_prompt(GuideBuildContext::default());
775        assert!(prompt.contains("## Tool Usage Guidelines"));
776        assert!(prompt.contains("**Read**"));
777    }
778
779    #[test]
780    fn test_executor_builder_empty() {
781        let executor = BuiltinToolExecutorBuilder::new().build();
782        assert!(executor.list_tools().is_empty());
783    }
784
785    #[test]
786    fn test_executor_builder_with_default_tools() {
787        let executor = BuiltinToolExecutorBuilder::new()
788            .with_default_tools()
789            .build();
790        assert_eq!(executor.list_tools().len(), BUILTIN_TOOL_NAMES.len());
791    }
792
793    #[test]
794    fn test_executor_builder_with_specific_tool() {
795        let executor = BuiltinToolExecutorBuilder::new()
796            .with_filesystem_tool("Read")
797            .unwrap()
798            .build();
799
800        let tools = executor.list_tools();
801        assert_eq!(tools.len(), 1);
802        assert_eq!(tools[0].function.name, "Read");
803    }
804
805    #[tokio::test]
806    async fn test_executor_skips_permission_checks_without_checker() {
807        let executor = make_executor(None);
808        let path = "/tmp/executor_permission_none.txt";
809        let _ = fs::remove_file(path).await;
810
811        let call = make_tool_call("Write", json!({"file_path": path, "content": "ok"}));
812        let result = executor.execute(&call).await.expect("execute tool");
813
814        assert!(result.success);
815        let _ = fs::remove_file(path).await;
816    }
817
818    #[tokio::test]
819    async fn test_executor_with_permission_checker_enforces_checks() {
820        let checker = Arc::new(crate::permission::DenyDangerousPermissionChecker);
821        let executor = make_executor(Some(checker));
822        let path = "/tmp/executor_permission_denied.txt";
823        let _ = fs::remove_file(path).await;
824
825        let call = make_tool_call("Write", json!({"file_path": path, "content": "nope"}));
826        let result = executor.execute(&call).await;
827
828        assert!(matches!(result, Err(ToolError::Execution(_))));
829        assert!(fs::metadata(path).await.is_err());
830    }
831
832    #[tokio::test]
833    async fn tool_can_stream_events_via_execute_with_context() {
834        struct StreamingTool;
835
836        #[async_trait]
837        impl Tool for StreamingTool {
838            fn name(&self) -> &str {
839                "streaming_tool"
840            }
841
842            fn description(&self) -> &str {
843                "streams one token"
844            }
845
846            fn parameters_schema(&self) -> serde_json::Value {
847                json!({"type":"object","properties":{}})
848            }
849
850            async fn execute(&self, _args: serde_json::Value) -> Result<ToolResult, ToolError> {
851                Ok(ToolResult {
852                    success: true,
853                    result: "ok".to_string(),
854                    display_preference: None,
855                })
856            }
857
858            async fn execute_with_context(
859                &self,
860                args: serde_json::Value,
861                ctx: ToolExecutionContext<'_>,
862            ) -> Result<ToolResult, ToolError> {
863                ctx.emit(AgentEvent::Token {
864                    content: "stream".to_string(),
865                })
866                .await;
867                self.execute(args).await
868            }
869        }
870
871        let executor = BuiltinToolExecutor::new();
872        executor
873            .register_tool(StreamingTool)
874            .expect("register streaming tool");
875
876        let (tx, mut rx) = mpsc::channel(8);
877        let call = make_tool_call("streaming_tool", json!({}));
878
879        let result = executor
880            .execute_with_context(
881                &call,
882                ToolExecutionContext {
883                    session_id: Some("s1"),
884                    tool_call_id: &call.id,
885                    event_tx: Some(&tx),
886                    available_tool_schemas: None,
887                },
888            )
889            .await
890            .expect("execute tool");
891
892        assert!(result.success);
893        assert_eq!(result.result, "ok");
894
895        let ev = rx.recv().await.expect("expected streamed event");
896        assert!(
897            matches!(ev, AgentEvent::ToolToken { tool_call_id, content } if tool_call_id == "call_1" && content == "stream")
898        );
899    }
900
901    #[tokio::test]
902    async fn removed_legacy_tools_return_not_found() {
903        let executor = BuiltinToolExecutor::new();
904
905        for legacy in ["claude_code", "search_in_file", "search_in_project"] {
906            let call = make_tool_call(legacy, json!({}));
907            let result = executor.execute(&call).await;
908            assert!(matches!(result, Err(ToolError::NotFound(_))));
909        }
910    }
911
912    #[tokio::test]
913    async fn executor_prefers_exact_tool_name_before_builtin_alias() {
914        struct CustomSpawnSessionTool;
915
916        #[async_trait]
917        impl Tool for CustomSpawnSessionTool {
918            fn name(&self) -> &str {
919                "spawn_session"
920            }
921
922            fn description(&self) -> &str {
923                "custom tool for regression coverage"
924            }
925
926            fn parameters_schema(&self) -> serde_json::Value {
927                json!({"type":"object","properties":{}})
928            }
929
930            async fn execute(&self, _args: serde_json::Value) -> Result<ToolResult, ToolError> {
931                Ok(ToolResult {
932                    success: true,
933                    result: "custom-spawn-session".to_string(),
934                    display_preference: None,
935                })
936            }
937        }
938
939        let executor = BuiltinToolExecutorBuilder::new()
940            .with_tool(CustomSpawnSessionTool)
941            .expect("register custom spawn_session tool")
942            .build();
943
944        let call = make_tool_call("spawn_session", json!({}));
945        let result = executor.execute(&call).await.expect("execute custom tool");
946        assert!(result.success);
947        assert_eq!(result.result, "custom-spawn-session");
948    }
949}