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                // "Always ask" rules (configured patterns + built-in dangerous
283                // commands) force a confirmation even under bypass. Everything
284                // else is skipped when this session is in "bypass permissions"
285                // mode (scoped per-session via its runtime state).
286                let force_ask = permission_checker.requires_forced_confirmation(&tool_name, &args);
287                for context in contexts {
288                    if ctx.bypass_permissions && !force_ask {
289                        continue;
290                    }
291                    let resource = context.resource.clone();
292                    // Forced confirmations route through `check_or_request_forced`
293                    // so the active mode/bypass cannot suppress the prompt.
294                    let decision = if force_ask {
295                        permission_checker.check_or_request_forced(context).await
296                    } else {
297                        permission_checker.check_or_request(context).await
298                    };
299                    match decision {
300                        Ok(true) => {}
301                        Ok(false) => {
302                            return Err(ToolError::Execution(format!(
303                                "Permission denied for: {}",
304                                resource
305                            )));
306                        }
307                        Err(PermissionError::ConfirmationRequired {
308                            permission_type,
309                            resource: _,
310                        }) => {
311                            // Phase 2 (cross-process): a subagent worker installs a
312                            // task-local `ApprovalProxy` for the duration of its run.
313                            // When present, forward the decision to the worker's host
314                            // (parent) and block this tool inline for the reply —
315                            // approve proceeds (treated like a granted context), deny
316                            // fails closed. Checked BEFORE the interactive sink below
317                            // so a worker (which also has an `event_tx`) proxies to its
318                            // parent instead of trying to prompt a human itself. The
319                            // proxy is unset on every non-worker path, so the behavior
320                            // there is unchanged.
321                            if let Some(proxy) = crate::approval::current_approval_proxy() {
322                                let approved = proxy
323                                    .request_approval(crate::approval::ApprovalAsk {
324                                        tool_name: tool_name.clone(),
325                                        permission: permission_type.description().to_string(),
326                                        resource: resource.clone(),
327                                    })
328                                    .await;
329                                if approved {
330                                    // Treat as a granted context: check any remaining
331                                    // contexts, then fall through to execution.
332                                    continue;
333                                }
334                                return Err(ToolError::Execution(format!(
335                                    "Permission denied by host for: {}",
336                                    resource
337                                )));
338                            }
339
340                            // Interactive sessions pause for approval by reusing the
341                            // same pending-question pipeline as `request_permissions`:
342                            // synthesize an "awaiting_permission_approval" result that
343                            // the engine recognizes (via display_preference) and turns
344                            // into a NeedClarification pause. On approval the respond
345                            // handler records a session grant so the re-attempt passes.
346                            if let Some(tx) = ctx.event_tx {
347                                // Keep emitting the structured approval event for observers.
348                                let _ = tx
349                                    .send(bamboo_agent_core::AgentEvent::ToolApprovalRequested {
350                                        tool_call_id: call.id.clone(),
351                                        tool_name: tool_name.clone(),
352                                        parameters: args.clone(),
353                                    })
354                                    .await;
355
356                                let question = format!(
357                                    "**Permission required**\n\nThe `{}` tool needs approval to {} on:\n\n`{}`",
358                                    tool_name,
359                                    permission_type.description(),
360                                    resource
361                                );
362                                let payload = serde_json::json!({
363                                    "status": "awaiting_permission_approval",
364                                    "question": question,
365                                    "permission_type": permission_type,
366                                    "resource": resource,
367                                    "options": ["Approve", "Deny"],
368                                    "allow_custom": false,
369                                });
370                                return Ok(ToolResult {
371                                    success: true,
372                                    result: payload.to_string(),
373                                    display_preference: Some("request_permissions".to_string()),
374                                    images: Vec::new(),
375                                });
376                            }
377
378                            // Non-interactive (no event sink to surface the prompt):
379                            // fail closed rather than silently proceeding.
380                            return Err(ToolError::Execution(format!(
381                                "Permission approval required for: {}",
382                                resource
383                            )));
384                        }
385                        Err(other) => {
386                            return Err(permission_error_to_tool_error(other));
387                        }
388                    }
389                }
390            }
391        }
392
393        tool.execute_with_context(args, ctx).await
394    }
395
396    fn list_tools(&self) -> Vec<ToolSchema> {
397        self.registry.list_tools()
398    }
399
400    fn tool_mutability(&self, tool_name: &str) -> crate::ToolMutability {
401        self.registry
402            .get(tool_name)
403            .map(|tool| tool.mutability())
404            .unwrap_or_else(|| crate::classify_tool(tool_name))
405    }
406
407    fn call_mutability(&self, call: &ToolCall) -> crate::ToolMutability {
408        let canonical = resolve_registered_tool_name(&self.registry, call.function.name.trim());
409        let args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
410        self.registry
411            .get(&canonical)
412            .map(|tool| tool.call_mutability(&args))
413            .unwrap_or_else(|| self.tool_mutability(&canonical))
414    }
415
416    fn tool_concurrency_safe(&self, tool_name: &str) -> bool {
417        let canonical = resolve_registered_tool_name(&self.registry, tool_name);
418        self.registry
419            .get(&canonical)
420            .map(|tool| tool.concurrency_safe())
421            .unwrap_or_else(|| self.tool_mutability(&canonical) == crate::ToolMutability::ReadOnly)
422    }
423
424    fn call_concurrency_safe(&self, call: &ToolCall) -> bool {
425        let canonical = resolve_registered_tool_name(&self.registry, call.function.name.trim());
426        let args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
427        self.registry
428            .get(&canonical)
429            .map(|tool| tool.call_concurrency_safe(&args))
430            .unwrap_or_else(|| self.tool_concurrency_safe(&canonical))
431    }
432}
433
434/// Builder for constructing a BuiltinToolExecutor with custom tool configurations
435pub struct BuiltinToolExecutorBuilder {
436    registry: ToolRegistry,
437    permission_checker: Option<Arc<dyn PermissionChecker>>,
438}
439
440impl BuiltinToolExecutorBuilder {
441    /// Creates a new builder with no tools registered
442    pub fn new() -> Self {
443        Self {
444            registry: ToolRegistry::new(),
445            permission_checker: None,
446        }
447    }
448
449    /// Registers all default built-in tools
450    pub fn with_default_tools(self) -> Self {
451        BuiltinToolExecutor::register_builtin_tools(&self.registry, None);
452        self
453    }
454
455    /// Registers a specific filesystem tool by name
456    pub fn with_filesystem_tool(self, name: &str) -> Result<Self, ToolError> {
457        match name {
458            "Read" => self.registry.register(ReadTool::new()),
459            "Write" => self.registry.register(WriteTool::new()),
460            // apply_patch is now an alias for Edit
461            "Edit" | "apply_patch" => self.registry.register(EditTool::new()),
462            "NotebookEdit" => self.registry.register(NotebookEditTool::new()),
463            _ => return Err(ToolError::NotFound(format!("Unknown tool: {}", name))),
464        }
465        .map_err(|e| ToolError::Execution(e.to_string()))?;
466        Ok(self)
467    }
468
469    /// Registers a specific command tool by name
470    pub fn with_command_tool(self, name: &str) -> Result<Self, ToolError> {
471        match name {
472            "Bash" => self.registry.register(BashTool::new()),
473            "BashOutput" => self.registry.register(BashOutputTool::new()),
474            "KillShell" => self.registry.register(KillShellTool::new()),
475            "Task" => self.registry.register(TaskTool::new()),
476            _ => return Err(ToolError::NotFound(format!("Unknown tool: {}", name))),
477        }
478        .map_err(|e| ToolError::Execution(e.to_string()))?;
479        Ok(self)
480    }
481
482    /// Registers a custom tool
483    pub fn with_tool<T: Tool + 'static>(self, tool: T) -> Result<Self, ToolError> {
484        self.registry
485            .register(tool)
486            .map_err(|e| ToolError::Execution(e.to_string()))?;
487        Ok(self)
488    }
489
490    /// Sets a permission checker for this executor
491    pub fn with_permission_checker(mut self, checker: Arc<dyn PermissionChecker>) -> Self {
492        self.permission_checker = Some(checker);
493        self
494    }
495
496    /// Builds the executor
497    pub fn build(self) -> BuiltinToolExecutor {
498        BuiltinToolExecutor {
499            registry: self.registry,
500            permission_checker: self.permission_checker,
501        }
502    }
503}
504
505impl Default for BuiltinToolExecutorBuilder {
506    fn default() -> Self {
507        Self::new()
508    }
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514    use bamboo_agent_core::AgentEvent;
515    use bamboo_agent_core::FunctionCall;
516    use bamboo_agent_core::ToolExecutionContext;
517    use bamboo_domain::tool_names::{normalize_tool_ref, BUILTIN_TOOL_NAMES};
518    use serde_json::json;
519    use std::sync::Arc;
520    use tokio::fs;
521    use tokio::sync::mpsc;
522
523    use crate::tools::WriteTool;
524
525    fn make_tool_call(name: &str, args: serde_json::Value) -> ToolCall {
526        ToolCall {
527            id: "call_1".to_string(),
528            tool_type: "function".to_string(),
529            function: FunctionCall {
530                name: name.to_string(),
531                arguments: args.to_string(),
532            },
533        }
534    }
535
536    fn make_tool_call_with_raw_args(name: &str, raw_args: &str) -> ToolCall {
537        ToolCall {
538            id: "call_1".to_string(),
539            tool_type: "function".to_string(),
540            function: FunctionCall {
541                name: name.to_string(),
542                arguments: raw_args.to_string(),
543            },
544        }
545    }
546
547    fn make_executor(
548        permission_checker: Option<Arc<dyn PermissionChecker>>,
549    ) -> BuiltinToolExecutor {
550        let builder = BuiltinToolExecutorBuilder::new()
551            .with_tool(WriteTool::new())
552            .expect("register Write tool");
553
554        let builder = match permission_checker {
555            Some(checker) => builder.with_permission_checker(checker),
556            None => builder,
557        };
558
559        builder.build()
560    }
561
562    #[test]
563    fn test_normalize_tool_ref_accepts_claude_style_names() {
564        assert_eq!(
565            normalize_tool_ref("default::Bash"),
566            Some("Bash".to_string())
567        );
568    }
569
570    #[test]
571    fn test_normalize_tool_ref_accepts_legacy_camel_aliases() {
572        assert_eq!(
573            normalize_tool_ref("default::fileExists"),
574            Some("FileExists".to_string())
575        );
576        assert_eq!(
577            normalize_tool_ref("default::getCurrentDir"),
578            Some("GetCurrentDir".to_string())
579        );
580        assert_eq!(
581            normalize_tool_ref("default::getFileInfo"),
582            Some("GetFileInfo".to_string())
583        );
584        assert_eq!(
585            normalize_tool_ref("default::setWorkspace"),
586            Some("SetWorkspace".to_string())
587        );
588        assert_eq!(
589            normalize_tool_ref("default::sleep"),
590            Some("Sleep".to_string())
591        );
592    }
593
594    #[test]
595    fn test_normalize_tool_ref_accepts_legacy_snake_case_aliases() {
596        assert_eq!(
597            normalize_tool_ref("default::execute_command"),
598            Some("Bash".to_string())
599        );
600        assert_eq!(
601            normalize_tool_ref("default::file_exists"),
602            Some("FileExists".to_string())
603        );
604        assert_eq!(
605            normalize_tool_ref("default::get_current_dir"),
606            Some("GetCurrentDir".to_string())
607        );
608        assert_eq!(
609            normalize_tool_ref("default::get_file_info"),
610            Some("GetFileInfo".to_string())
611        );
612        assert_eq!(
613            normalize_tool_ref("default::list_directory"),
614            Some("Glob".to_string())
615        );
616        assert_eq!(
617            normalize_tool_ref("default::memory_note"),
618            Some("memory_note".to_string())
619        );
620        assert_eq!(
621            normalize_tool_ref("default::read_file"),
622            Some("Read".to_string())
623        );
624        assert_eq!(
625            normalize_tool_ref("default::set_workspace"),
626            Some("SetWorkspace".to_string())
627        );
628        assert_eq!(
629            normalize_tool_ref("default::write_file"),
630            Some("Write".to_string())
631        );
632    }
633
634    #[test]
635    fn test_normalize_tool_ref_accepts_spawn_task_aliases() {
636        for alias in [
637            "default::spawn_session",
638            "default::sub_session",
639            "default::sub_task",
640            "default::team_agent",
641            "default::child_session",
642        ] {
643            assert_eq!(normalize_tool_ref(alias), Some("SubAgent".to_string()));
644        }
645    }
646
647    #[test]
648    fn test_normalize_tool_ref_accepts_server_overlay_tools() {
649        assert_eq!(normalize_tool_ref("compress_context"), None);
650        assert_eq!(
651            normalize_tool_ref("default::read_skill_resource"),
652            Some("read_skill_resource".to_string())
653        );
654    }
655
656    #[tokio::test]
657    async fn test_executor_accepts_legacy_read_file_path_argument() {
658        let dir = tempfile::tempdir().unwrap();
659        let file_path = dir.path().join("legacy-read.txt");
660        fs::write(&file_path, "legacy read content").await.unwrap();
661
662        let executor = BuiltinToolExecutor::new();
663        let call = make_tool_call("read_file", json!({"path": file_path}));
664
665        let result = executor.execute(&call).await.unwrap();
666        assert!(result.success);
667        assert!(result.result.contains("legacy read content"));
668    }
669
670    #[tokio::test]
671    async fn test_executor_accepts_legacy_list_directory_without_pattern() {
672        let dir = tempfile::tempdir().unwrap();
673        let file_path = dir.path().join("legacy-list.txt");
674        fs::write(&file_path, "legacy list content").await.unwrap();
675
676        let executor = BuiltinToolExecutor::new();
677        let call = make_tool_call("list_directory", json!({"path": dir.path()}));
678
679        let result = executor.execute(&call).await.unwrap();
680        assert!(result.success);
681        assert!(result.result.contains("legacy-list.txt"));
682    }
683
684    #[tokio::test]
685    async fn test_executor_accepts_canonical_read_with_path_argument() {
686        let dir = tempfile::tempdir().unwrap();
687        let file_path = dir.path().join("canonical-read.txt");
688        fs::write(&file_path, "canonical read content")
689            .await
690            .unwrap();
691
692        let executor = BuiltinToolExecutor::new();
693        let call = make_tool_call("Read", json!({"path": file_path}));
694
695        let result = executor.execute(&call).await.unwrap();
696        assert!(result.success);
697        assert!(result.result.contains("canonical read content"));
698    }
699
700    #[tokio::test]
701    async fn test_executor_accepts_canonical_glob_without_pattern_when_path_present() {
702        let dir = tempfile::tempdir().unwrap();
703        let file_path = dir.path().join("canonical-list.txt");
704        fs::write(&file_path, "canonical list content")
705            .await
706            .unwrap();
707
708        let executor = BuiltinToolExecutor::new();
709        let call = make_tool_call("Glob", json!({"path": dir.path()}));
710
711        let result = executor.execute(&call).await.unwrap();
712        assert!(result.success);
713        assert!(result.result.contains("canonical-list.txt"));
714    }
715
716    #[test]
717    fn test_executor_workspace_mutability_depends_on_path_argument() {
718        let executor = BuiltinToolExecutor::new();
719        let get_call = make_tool_call("Workspace", json!({}));
720        let set_call = make_tool_call("Workspace", json!({"path": "/tmp"}));
721
722        assert_eq!(
723            executor.call_mutability(&get_call),
724            crate::ToolMutability::ReadOnly
725        );
726        assert!(executor.call_concurrency_safe(&get_call));
727
728        assert_eq!(
729            executor.call_mutability(&set_call),
730            crate::ToolMutability::Mutating
731        );
732        assert!(!executor.call_concurrency_safe(&set_call));
733    }
734
735    #[tokio::test]
736    async fn test_executor_recovers_truncated_json_arguments() {
737        let dir = tempfile::tempdir().unwrap();
738        let path = dir.path().join("recovered-write.txt");
739
740        // Missing closing brace simulates EOF while parsing an object.
741        let malformed_args = format!(
742            r#"{{"file_path":"{}","content":"recovered content""#,
743            path.display()
744        );
745
746        let executor = BuiltinToolExecutor::new();
747        let call = make_tool_call_with_raw_args("Write", &malformed_args);
748
749        let result = executor
750            .execute(&call)
751            .await
752            .expect("truncated JSON should be auto-repaired");
753        assert!(result.success);
754
755        let written = fs::read_to_string(&path)
756            .await
757            .expect("file should be written");
758        assert_eq!(written, "recovered content");
759    }
760
761    #[test]
762    fn test_normalize_tool_ref_rejects_unknown_tool() {
763        assert_eq!(normalize_tool_ref("default::search"), None);
764    }
765
766    #[test]
767    fn test_executor_does_not_expose_legacy_tools() {
768        let executor = BuiltinToolExecutor::new();
769        let tool_names: Vec<String> = executor
770            .list_tools()
771            .into_iter()
772            .map(|schema| schema.function.name)
773            .collect();
774
775        for legacy in ["claude_code", "search_in_file", "search_in_project"] {
776            assert!(!tool_names.iter().any(|name| name == legacy));
777        }
778    }
779
780    #[test]
781    fn test_critical_tool_schemas_match_claude_shapes() {
782        let executor = BuiltinToolExecutor::new();
783        let tools = executor.list_tools();
784
785        let get_params = |name: &str| {
786            tools
787                .iter()
788                .find(|tool| tool.function.name == name)
789                .unwrap()
790                .function
791                .parameters
792                .clone()
793        };
794
795        let grep = get_params("Grep");
796        assert_eq!(grep["required"], json!(["pattern"]));
797        assert_eq!(
798            grep["properties"]["output_mode"]["enum"],
799            json!(["content", "files_with_matches", "count"])
800        );
801        assert!(grep["properties"]["-A"].is_object());
802        assert!(grep["properties"]["-B"].is_object());
803        assert!(grep["properties"]["-C"].is_object());
804        assert!(grep["properties"]["-n"].is_object());
805        assert!(grep["properties"]["-i"].is_object());
806
807        let edit = get_params("Edit");
808        assert_eq!(edit["required"], json!(["file_path"]));
809        assert_eq!(edit["properties"]["old_string"]["type"], "string");
810        assert_eq!(edit["properties"]["new_string"]["type"], "string");
811        assert_eq!(edit["properties"]["patch"]["type"], "string");
812        assert_eq!(edit["properties"]["replace_all"]["type"], "boolean");
813        assert!(edit.get("oneOf").is_none());
814
815        // apply_patch is now an alias for Edit – its schema is the Edit
816        // schema, so we just verify that Edit includes the patch property.
817        assert_eq!(edit["properties"]["patch"]["type"], "string");
818        assert_eq!(edit["properties"]["line_number"]["type"], "integer");
819
820        let bash = get_params("Bash");
821        assert_eq!(bash["required"], json!(["command"]));
822        assert_eq!(bash["properties"]["run_in_background"]["type"], "boolean");
823        assert_eq!(bash["properties"]["workdir"]["type"], "string");
824
825        let bash_output = get_params("BashOutput");
826        assert_eq!(bash_output["required"], json!(["bash_id"]));
827        assert_eq!(bash_output["properties"]["filter"]["type"], "string");
828    }
829
830    #[test]
831    fn test_tool_schemas_avoid_openai_forbidden_top_level_keywords() {
832        let executor = BuiltinToolExecutor::new();
833        let tools = executor.list_tools();
834        let forbidden = ["oneOf", "anyOf", "allOf", "not", "enum"];
835
836        for tool in tools {
837            let params = &tool.function.parameters;
838            assert_eq!(
839                params["type"], "object",
840                "tool '{}' parameters must be a top-level object schema",
841                tool.function.name
842            );
843            for key in forbidden {
844                assert!(
845                    params.get(key).is_none(),
846                    "tool '{}' parameters contains forbidden top-level keyword '{}'",
847                    tool.function.name,
848                    key
849                );
850            }
851        }
852    }
853
854    #[test]
855    fn test_executor_has_all_builtin_tools() {
856        let executor = BuiltinToolExecutor::new();
857        let tools = executor.list_tools();
858
859        assert_eq!(tools.len(), BUILTIN_TOOL_NAMES.len());
860
861        let tool_names: Vec<String> = tools.iter().map(|t| t.function.name.clone()).collect();
862        for tool_name in BUILTIN_TOOL_NAMES {
863            assert!(tool_names.contains(&tool_name.to_string()));
864        }
865    }
866
867    #[test]
868    fn test_executor_builds_enhanced_prompt() {
869        let executor = BuiltinToolExecutor::new();
870        let prompt = executor.build_enhanced_prompt(GuideBuildContext::default());
871        assert!(prompt.contains("## Tool Usage Guidelines"));
872        assert!(prompt.contains("**Read**"));
873    }
874
875    #[test]
876    fn test_executor_builder_empty() {
877        let executor = BuiltinToolExecutorBuilder::new().build();
878        assert!(executor.list_tools().is_empty());
879    }
880
881    #[test]
882    fn test_executor_builder_with_default_tools() {
883        let executor = BuiltinToolExecutorBuilder::new()
884            .with_default_tools()
885            .build();
886        assert_eq!(executor.list_tools().len(), BUILTIN_TOOL_NAMES.len());
887    }
888
889    #[test]
890    fn test_executor_builder_with_specific_tool() {
891        let executor = BuiltinToolExecutorBuilder::new()
892            .with_filesystem_tool("Read")
893            .unwrap()
894            .build();
895
896        let tools = executor.list_tools();
897        assert_eq!(tools.len(), 1);
898        assert_eq!(tools[0].function.name, "Read");
899    }
900
901    #[tokio::test]
902    async fn test_executor_skips_permission_checks_without_checker() {
903        let executor = make_executor(None);
904        let path = "/tmp/executor_permission_none.txt";
905        let _ = fs::remove_file(path).await;
906
907        let call = make_tool_call("Write", json!({"file_path": path, "content": "ok"}));
908        let result = executor.execute(&call).await.expect("execute tool");
909
910        assert!(result.success);
911        let _ = fs::remove_file(path).await;
912    }
913
914    #[tokio::test]
915    async fn test_executor_with_permission_checker_enforces_checks() {
916        let checker = Arc::new(crate::permission::DenyDangerousPermissionChecker);
917        let executor = make_executor(Some(checker));
918        let path = "/tmp/executor_permission_denied.txt";
919        let _ = fs::remove_file(path).await;
920
921        let call = make_tool_call("Write", json!({"file_path": path, "content": "nope"}));
922        let result = executor.execute(&call).await;
923
924        assert!(matches!(result, Err(ToolError::Execution(_))));
925        assert!(fs::metadata(path).await.is_err());
926    }
927
928    #[tokio::test]
929    async fn test_bypass_permissions_skips_checker() {
930        // Even with a deny-all checker, a context flagged `bypass_permissions`
931        // must skip the permission check entirely and let the write through.
932        let checker = Arc::new(crate::permission::DenyDangerousPermissionChecker);
933        let executor = make_executor(Some(checker));
934        let dir = tempfile::tempdir().unwrap();
935        let path = dir.path().join("bypass_allows_write.txt");
936        let path_str = path.to_str().unwrap();
937
938        let call = make_tool_call("Write", json!({"file_path": path_str, "content": "ok"}));
939        let ctx = ToolExecutionContext {
940            session_id: Some("s-bypass"),
941            tool_call_id: &call.id,
942            event_tx: None,
943            available_tool_schemas: None,
944            bypass_permissions: true,
945        };
946        let result = executor.execute_with_context(&call, ctx).await;
947
948        assert!(result.is_ok(), "bypass should allow the write: {result:?}");
949        assert_eq!(fs::read_to_string(&path).await.unwrap(), "ok");
950    }
951
952    #[tokio::test]
953    async fn test_forced_ask_rule_overrides_bypass() {
954        // A configured "always ask" rule must force a confirmation even when the
955        // session is in bypass mode. With no event sink the executor fails closed
956        // (approval required) rather than silently writing.
957        let config = Arc::new(crate::permission::PermissionConfig::new());
958        config.set_ask_rules(["Write(/etc/**)".to_string()]);
959        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
960        let executor = make_executor(Some(checker));
961
962        let call = make_tool_call(
963            "Write",
964            json!({"file_path": "/etc/forced.conf", "content": "x"}),
965        );
966        let ctx = ToolExecutionContext {
967            session_id: Some("s-forced"),
968            tool_call_id: &call.id,
969            event_tx: None,
970            available_tool_schemas: None,
971            bypass_permissions: true,
972        };
973        let result = executor.execute_with_context(&call, ctx).await;
974
975        assert!(
976            matches!(result, Err(ToolError::Execution(ref m)) if m.contains("approval required")),
977            "forced ask rule should block under bypass: {result:?}"
978        );
979        assert!(fs::metadata("/etc/forced.conf").await.is_err());
980    }
981
982    // ---- Phase 2: cross-process approval proxy ----------------------------
983
984    struct HostStub {
985        approve: bool,
986    }
987
988    #[async_trait]
989    impl crate::approval::ApprovalProxy for HostStub {
990        async fn request_approval(&self, _ask: crate::approval::ApprovalAsk) -> bool {
991            self.approve
992        }
993    }
994
995    #[tokio::test]
996    async fn approval_proxy_grant_lets_gated_tool_proceed() {
997        // A subagent worker installs an ApprovalProxy for its run. A forced-ask
998        // rule with NO event sink would otherwise fail closed; with the host
999        // proxy granting, the executor treats the context as approved and the
1000        // tool proceeds inline (no suspend, no synthetic pause).
1001        let dir = tempfile::tempdir().unwrap();
1002        let path = dir.path().join("approved.txt");
1003        let path_str = path.to_str().unwrap().to_string();
1004        let config = Arc::new(crate::permission::PermissionConfig::new());
1005        config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
1006        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1007        let executor = make_executor(Some(checker));
1008
1009        let call = make_tool_call("Write", json!({"file_path": path_str, "content": "ok"}));
1010        let ctx = ToolExecutionContext {
1011            session_id: Some("s-worker"),
1012            tool_call_id: &call.id,
1013            event_tx: None,
1014            available_tool_schemas: None,
1015            bypass_permissions: false,
1016        };
1017
1018        let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(HostStub { approve: true });
1019        let result = crate::approval::with_approval_proxy(
1020            Some(proxy),
1021            executor.execute_with_context(&call, ctx),
1022        )
1023        .await;
1024
1025        assert!(
1026            result.is_ok(),
1027            "host grant should let the write through: {result:?}"
1028        );
1029        assert_eq!(fs::read_to_string(&path).await.unwrap(), "ok");
1030    }
1031
1032    #[tokio::test]
1033    async fn approval_proxy_deny_fails_gated_tool_closed() {
1034        // With the host proxy denying, the gated tool fails closed and the side
1035        // effect never happens.
1036        let dir = tempfile::tempdir().unwrap();
1037        let path = dir.path().join("denied.txt");
1038        let path_str = path.to_str().unwrap().to_string();
1039        let config = Arc::new(crate::permission::PermissionConfig::new());
1040        config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
1041        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1042        let executor = make_executor(Some(checker));
1043
1044        let call = make_tool_call("Write", json!({"file_path": path_str, "content": "nope"}));
1045        let ctx = ToolExecutionContext {
1046            session_id: Some("s-worker"),
1047            tool_call_id: &call.id,
1048            event_tx: None,
1049            available_tool_schemas: None,
1050            bypass_permissions: false,
1051        };
1052
1053        let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(HostStub { approve: false });
1054        let result = crate::approval::with_approval_proxy(
1055            Some(proxy),
1056            executor.execute_with_context(&call, ctx),
1057        )
1058        .await;
1059
1060        assert!(
1061            matches!(result, Err(ToolError::Execution(ref m)) if m.contains("denied by host")),
1062            "host deny should fail the tool closed: {result:?}"
1063        );
1064        assert!(fs::metadata(&path).await.is_err());
1065    }
1066
1067    #[tokio::test]
1068    async fn tool_can_stream_events_via_execute_with_context() {
1069        struct StreamingTool;
1070
1071        #[async_trait]
1072        impl Tool for StreamingTool {
1073            fn name(&self) -> &str {
1074                "streaming_tool"
1075            }
1076
1077            fn description(&self) -> &str {
1078                "streams one token"
1079            }
1080
1081            fn parameters_schema(&self) -> serde_json::Value {
1082                json!({"type":"object","properties":{}})
1083            }
1084
1085            async fn execute(&self, _args: serde_json::Value) -> Result<ToolResult, ToolError> {
1086                Ok(ToolResult {
1087                    success: true,
1088                    result: "ok".to_string(),
1089                    display_preference: None,
1090                    images: Vec::new(),
1091                })
1092            }
1093
1094            async fn execute_with_context(
1095                &self,
1096                args: serde_json::Value,
1097                ctx: ToolExecutionContext<'_>,
1098            ) -> Result<ToolResult, ToolError> {
1099                ctx.emit(AgentEvent::Token {
1100                    content: "stream".to_string(),
1101                })
1102                .await;
1103                self.execute(args).await
1104            }
1105        }
1106
1107        let executor = BuiltinToolExecutor::new();
1108        executor
1109            .register_tool(StreamingTool)
1110            .expect("register streaming tool");
1111
1112        let (tx, mut rx) = mpsc::channel(8);
1113        let call = make_tool_call("streaming_tool", json!({}));
1114
1115        let result = executor
1116            .execute_with_context(
1117                &call,
1118                ToolExecutionContext {
1119                    session_id: Some("s1"),
1120                    tool_call_id: &call.id,
1121                    event_tx: Some(&tx),
1122                    available_tool_schemas: None,
1123                    bypass_permissions: false,
1124                },
1125            )
1126            .await
1127            .expect("execute tool");
1128
1129        assert!(result.success);
1130        assert_eq!(result.result, "ok");
1131
1132        let ev = rx.recv().await.expect("expected streamed event");
1133        assert!(
1134            matches!(ev, AgentEvent::ToolToken { tool_call_id, content } if tool_call_id == "call_1" && content == "stream")
1135        );
1136    }
1137
1138    #[tokio::test]
1139    async fn removed_legacy_tools_return_not_found() {
1140        let executor = BuiltinToolExecutor::new();
1141
1142        for legacy in ["claude_code", "search_in_file", "search_in_project"] {
1143            let call = make_tool_call(legacy, json!({}));
1144            let result = executor.execute(&call).await;
1145            assert!(matches!(result, Err(ToolError::NotFound(_))));
1146        }
1147    }
1148
1149    #[tokio::test]
1150    async fn executor_prefers_exact_tool_name_before_builtin_alias() {
1151        struct CustomSpawnSessionTool;
1152
1153        #[async_trait]
1154        impl Tool for CustomSpawnSessionTool {
1155            fn name(&self) -> &str {
1156                "spawn_session"
1157            }
1158
1159            fn description(&self) -> &str {
1160                "custom tool for regression coverage"
1161            }
1162
1163            fn parameters_schema(&self) -> serde_json::Value {
1164                json!({"type":"object","properties":{}})
1165            }
1166
1167            async fn execute(&self, _args: serde_json::Value) -> Result<ToolResult, ToolError> {
1168                Ok(ToolResult {
1169                    success: true,
1170                    result: "custom-spawn-session".to_string(),
1171                    display_preference: None,
1172                    images: Vec::new(),
1173                })
1174            }
1175        }
1176
1177        let executor = BuiltinToolExecutorBuilder::new()
1178            .with_tool(CustomSpawnSessionTool)
1179            .expect("register custom spawn_session tool")
1180            .build();
1181
1182        let call = make_tool_call("spawn_session", json!({}));
1183        let result = executor.execute(&call).await.expect("execute custom tool");
1184        assert!(result.success);
1185        assert_eq!(result.result, "custom-spawn-session");
1186    }
1187}