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