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