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