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