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