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, ToolOutcome, 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        self.execute_with_context_outcome(call, ctx)
253            .await
254            .map(ToolOutcome::into_tool_result)
255    }
256
257    async fn execute_with_context_outcome(
258        &self,
259        call: &ToolCall,
260        ctx: ToolExecutionContext<'_>,
261    ) -> Result<ToolOutcome, ToolError> {
262        // Reuse the args the dispatching agent loop already parsed (for the
263        // `ToolStart` event) when it threaded them through the context, instead
264        // of re-parsing the raw JSON string here (issue #106, deferred B1 from
265        // #17). The pre-parsed value is the exact output of
266        // `parse_tool_args_best_effort` on the same input, and that loop already
267        // logged any fallback warning at parse time, so skipping the re-parse is
268        // behavior-preserving. When absent (the `execute` entry point, tests, or
269        // a loop that parsed with a different/stricter parser), fall back to
270        // parsing here exactly as before — including the fallback-warning log.
271        let mut args = if let Some(pre_parsed) = ctx.pre_parsed_args {
272            pre_parsed.clone()
273        } else {
274            let args_raw = call.function.arguments.trim();
275            let (parsed, parse_warning) = parse_tool_args_best_effort(&call.function.arguments);
276            if let Some(warning) = parse_warning {
277                tracing::warn!(
278                    "Builtin tool argument parsing fallback applied: session_id={:?}, tool_call_id={}, tool_name={}, args_len={}, args_preview=\"{}\", warning={}",
279                    ctx.session_id,
280                    call.id,
281                    call.function.name,
282                    args_raw.len(),
283                    preview_for_log(args_raw, 180),
284                    warning
285                );
286            }
287            parsed
288        };
289
290        let raw_tool_name = normalize_tool_name(&call.function.name);
291        if let Some(args_obj) = args.as_object_mut() {
292            normalize_legacy_builtin_args(raw_tool_name, args_obj);
293        }
294
295        let tool_name = resolve_registered_tool_name(&self.registry, raw_tool_name);
296
297        // Look up the tool in the registry
298        let tool = self
299            .registry
300            .get(&tool_name)
301            .ok_or_else(|| ToolError::NotFound(format!("Tool '{}' not found", tool_name)))?;
302
303        // Permission gate. Factored onto the `ToolExecutor` trait
304        // (`check_permissions_for`) so overlay/wrapping executors can run the
305        // exact same check before invoking their own tools (issue #341). Kept
306        // AFTER the registry lookup so a `NotFound` still takes precedence,
307        // exactly as before. `Some(outcome)` is the interactive approval pause
308        // synthesized for a human sink; `Err` is deny / fail-closed.
309        if let Some(outcome) = self.check_permissions_for(call, &ctx).await? {
310            return Ok(outcome);
311        }
312
313        // Rewritten dispatch: build the owned `ToolCtx` at this concrete seam and
314        // call the tool's single `invoke`. Unwrap the `ToolOutcome` back to a
315        // `ToolResult` so the surrounding dispatch/loop is unchanged for now:
316        // `Completed` is the result; `Running`'s synthetic ack IS a `ToolResult`
317        // (preserving background Bash's current behavior); `NeedsHuman` cannot yet
318        // be produced (no tool returns it in this phase). Phase B makes the outcome
319        // authoritative and removes this unwrap.
320        let tool_ctx = ctx.to_tool_ctx();
321        tool.invoke(args, tool_ctx).await
322    }
323
324    /// The real permission gate for built-in tools, extracted from the execute
325    /// path so it is reusable by wrapping executors (issue #341). The behavior is
326    /// byte-for-byte the same block that used to run inline in
327    /// `execute_with_context_outcome`:
328    ///
329    /// - resolves the SAME `tool_name` + `args` the execute path runs with (so
330    ///   the check sees exactly what the tool will run with);
331    /// - "always ask" rules (`requires_forced_confirmation`) force a confirmation
332    ///   even under bypass; everything else is skipped when the session is in
333    ///   bypass-permissions mode;
334    /// - forced confirmations route through `check_or_request_forced` so the
335    ///   active mode/bypass can't suppress the prompt;
336    /// - a `ConfirmationRequired` first tries the cross-process `ApprovalProxy`
337    ///   (a subagent worker forwarding to its host), then the interactive human
338    ///   sink (returning the synthesized approval pause as `Ok(Some(..))`), then
339    ///   fails closed;
340    /// - deny fails closed.
341    ///
342    /// The only mechanical difference from the old inline block: the interactive
343    /// pause is returned as `Ok(Some(outcome))` and a clean pass returns
344    /// `Ok(None)`, so the caller decides whether to run the tool. The fallback
345    /// arg-parse warning is intentionally NOT re-logged here — the execute path
346    /// already logs it once for this call.
347    async fn check_permissions_for(
348        &self,
349        call: &ToolCall,
350        ctx: &ToolExecutionContext<'_>,
351    ) -> Result<Option<ToolOutcome>, ToolError> {
352        let Some(permission_checker) = &self.permission_checker else {
353            return Ok(None);
354        };
355
356        // Mirror the head of `execute_with_context_outcome`: reuse the pre-parsed
357        // args when threaded, apply the legacy-arg normalization, then resolve the
358        // registered/alias tool name. This is what makes the gate see the exact
359        // `tool_name`/`args` the tool will actually run with.
360        let mut args = if let Some(pre_parsed) = ctx.pre_parsed_args {
361            pre_parsed.clone()
362        } else {
363            parse_tool_args_best_effort(&call.function.arguments).0
364        };
365        let raw_tool_name = normalize_tool_name(&call.function.name);
366        if let Some(args_obj) = args.as_object_mut() {
367            normalize_legacy_builtin_args(raw_tool_name, args_obj);
368        }
369        let tool_name = resolve_registered_tool_name(&self.registry, raw_tool_name);
370
371        if let Some(contexts) =
372            check_permissions(&tool_name, &args).map_err(permission_error_to_tool_error)?
373        {
374            // "Always ask" rules (configured patterns + built-in dangerous
375            // commands) force a confirmation even under bypass. Everything
376            // else is skipped when this session is in "bypass permissions"
377            // mode (scoped per-session via its runtime state).
378            let force_ask = permission_checker.requires_forced_confirmation(&tool_name, &args);
379            for context in contexts {
380                // Explicit deny is a non-overridable policy outcome. Evaluate it
381                // before forced confirmation, remembered grants, and bypass.
382                if permission_checker
383                    .permission_config()
384                    .is_some_and(|config| {
385                        config.is_whitelist_allowed(context.permission_type, &context.resource)
386                            == Some(false)
387                    })
388                {
389                    return Err(ToolError::Execution(format!(
390                        "Permission denied by explicit policy for: {}",
391                        context.resource
392                    )));
393                }
394                if ctx.session_id.is_some_and(|session_id| {
395                    permission_checker.consume_once(
396                        session_id,
397                        &call.id,
398                        context.permission_type,
399                        &context.resource,
400                    )
401                }) {
402                    continue;
403                }
404                if ctx.bypass_permissions && !force_ask {
405                    continue;
406                }
407                let resource = context.resource.clone();
408                let operation_summary = context.operation_description.clone();
409                let risk_level = context.risk_level();
410                // Forced confirmations route through `check_or_request_forced`
411                // so the active mode/bypass cannot suppress the prompt.
412                let decision = if force_ask {
413                    permission_checker.check_or_request_forced(context).await
414                } else if let Some(session_id) = ctx.session_id {
415                    permission_checker
416                        .check_or_request_for_session(session_id, context)
417                        .await
418                } else {
419                    permission_checker.check_or_request(context).await
420                };
421                match decision {
422                    Ok(true) => {}
423                    Ok(false) => {
424                        return Err(ToolError::Execution(format!(
425                            "Permission denied for: {}",
426                            resource
427                        )));
428                    }
429                    Err(PermissionError::ConfirmationRequired {
430                        permission_type,
431                        resource: _,
432                    }) => {
433                        // Phase 2 (cross-process): a subagent worker installs a
434                        // task-local `ApprovalProxy` for the duration of its run.
435                        // When present, forward the decision to the worker's host
436                        // (parent) and block this tool inline for the reply —
437                        // approve proceeds (treated like a granted context), deny
438                        // fails closed. Checked BEFORE the interactive sink below
439                        // so a worker (which also has an `event_tx`) proxies to its
440                        // parent instead of trying to prompt a human itself. The
441                        // proxy is unset on every non-worker path, so the behavior
442                        // there is unchanged.
443                        if let Some(proxy) = crate::approval::current_approval_proxy() {
444                            let approved = proxy
445                                .request_approval(crate::approval::ApprovalAsk {
446                                    tool_name: tool_name.clone(),
447                                    permission: permission_type.description().to_string(),
448                                    resource: resource.clone(),
449                                })
450                                .await;
451                            if approved {
452                                // Treat as a granted context: check any remaining
453                                // contexts, then fall through to execution.
454                                continue;
455                            }
456                            return Err(ToolError::Execution(format!(
457                                "Permission denied by host for: {}",
458                                resource
459                            )));
460                        }
461
462                        // Interactive sessions pause for approval by reusing the
463                        // same pending-question pipeline as `request_permissions`:
464                        // synthesize an "awaiting_permission_approval" result that
465                        // the engine recognizes (via display_preference) and turns
466                        // into a NeedClarification pause. On approval the respond
467                        // handler records a session grant so the re-attempt passes.
468                        if let Some(tx) = ctx.event_tx {
469                            // Keep emitting the structured approval event for observers.
470                            let _ = tx
471                                .send(bamboo_agent_core::AgentEvent::ToolApprovalRequested {
472                                    tool_call_id: call.id.clone(),
473                                    tool_name: tool_name.clone(),
474                                    parameters: args.clone(),
475                                })
476                                .await;
477
478                            let question = format!(
479                                "**Permission required**\n\nThe `{}` tool needs approval to {} on:\n\n`{}`",
480                                tool_name,
481                                permission_type.description(),
482                                resource
483                            );
484                            let effective_mode = permission_checker
485                                .permission_config()
486                                .map(|config| config.mode())
487                                .unwrap_or(bamboo_config::settings::PermissionMode::Default);
488                            let permission_request = crate::permission::PermissionRequest {
489                                request_id: call.id.clone(),
490                                session_id: ctx.session_id.unwrap_or_default().to_string(),
491                                workspace_path: None,
492                                tool_name: tool_name.clone(),
493                                permission_type,
494                                resource: resource.clone(),
495                                operation_summary,
496                                risk_level,
497                                reason_code: if permission_checker.permission_config().is_some_and(
498                                    |config| config.is_hard_dangerous(&tool_name, &args),
499                                ) {
500                                    crate::permission::PermissionReasonCode::HardDangerous
501                                } else if force_ask {
502                                    crate::permission::PermissionReasonCode::ConfiguredAlwaysAsk
503                                } else {
504                                    crate::permission::PermissionReasonCode::RiskThreshold
505                                },
506                                effective_mode,
507                                bypass_requested: ctx.bypass_permissions,
508                                policy_revision: 0,
509                                allowed_decisions:
510                                    crate::permission::PermissionRequest::migration_decisions(),
511                                suggested_matchers: vec![crate::permission::PermissionMatcher {
512                                    id: "exact_resource".to_string(),
513                                    kind: crate::permission::PermissionMatcherKind::ExactResource,
514                                    value: resource.clone(),
515                                }],
516                            };
517                            let payload = serde_json::json!({
518                                "status": "awaiting_permission_approval",
519                                "question": question,
520                                "permission_type": permission_type,
521                                "resource": resource,
522                                "options": ["Approve", "Deny"],
523                                "allow_custom": false,
524                                "permission_request": permission_request,
525                            });
526                            // Permission-gate synthesized question stays on
527                            // the Completed→sniff path for now (a later Phase B
528                            // step converts this to ToolOutcome::NeedsHuman).
529                            return Ok(Some(ToolOutcome::Completed(ToolResult {
530                                success: true,
531                                result: payload.to_string(),
532                                display_preference: Some("request_permissions".to_string()),
533                                images: Vec::new(),
534                            })));
535                        }
536
537                        // Non-interactive (no event sink to surface the prompt):
538                        // fail closed rather than silently proceeding.
539                        return Err(ToolError::Execution(format!(
540                            "Permission approval required for: {}",
541                            resource
542                        )));
543                    }
544                    Err(other) => {
545                        return Err(permission_error_to_tool_error(other));
546                    }
547                }
548            }
549        }
550
551        Ok(None)
552    }
553
554    fn list_tools(&self) -> Vec<ToolSchema> {
555        self.registry.list_tools()
556    }
557
558    fn tool_mutability(&self, tool_name: &str) -> crate::ToolMutability {
559        self.registry
560            .get(tool_name)
561            .map(|tool| tool.classify(&serde_json::Value::Null).mutability)
562            .unwrap_or_else(|| crate::classify_tool(tool_name))
563    }
564
565    fn call_mutability(&self, call: &ToolCall) -> crate::ToolMutability {
566        let canonical = resolve_registered_tool_name(&self.registry, call.function.name.trim());
567        let args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
568        self.registry
569            .get(&canonical)
570            .map(|tool| tool.classify(&args).mutability)
571            .unwrap_or_else(|| self.tool_mutability(&canonical))
572    }
573
574    fn tool_concurrency_safe(&self, tool_name: &str) -> bool {
575        let canonical = resolve_registered_tool_name(&self.registry, tool_name);
576        self.registry
577            .get(&canonical)
578            .map(|tool| tool.classify(&serde_json::Value::Null).parallel_safe)
579            .unwrap_or_else(|| self.tool_mutability(&canonical) == crate::ToolMutability::ReadOnly)
580    }
581
582    fn call_concurrency_safe(&self, call: &ToolCall) -> bool {
583        let canonical = resolve_registered_tool_name(&self.registry, call.function.name.trim());
584        let args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
585        self.registry
586            .get(&canonical)
587            .map(|tool| tool.classify(&args).parallel_safe)
588            .unwrap_or_else(|| self.tool_concurrency_safe(&canonical))
589    }
590
591    fn call_parallel_classification(&self, call: &ToolCall) -> (crate::ToolMutability, bool) {
592        // One args-aware `classify` returns the (mutability, parallel_safe) pair
593        // with a single arg parse — the collapse of the former
594        // `call_mutability`/`call_concurrency_safe` pair.
595        let canonical = resolve_registered_tool_name(&self.registry, call.function.name.trim());
596        let args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
597        match self.registry.get(&canonical) {
598            Some(tool) => {
599                let class = tool.classify(&args);
600                (class.mutability, class.parallel_safe)
601            }
602            None => (
603                self.tool_mutability(&canonical),
604                self.tool_concurrency_safe(&canonical),
605            ),
606        }
607    }
608}
609
610/// Builder for constructing a BuiltinToolExecutor with custom tool configurations
611pub struct BuiltinToolExecutorBuilder {
612    registry: ToolRegistry,
613    permission_checker: Option<Arc<dyn PermissionChecker>>,
614}
615
616impl BuiltinToolExecutorBuilder {
617    /// Creates a new builder with no tools registered
618    pub fn new() -> Self {
619        Self {
620            registry: ToolRegistry::new(),
621            permission_checker: None,
622        }
623    }
624
625    /// Registers all default built-in tools
626    pub fn with_default_tools(self) -> Self {
627        BuiltinToolExecutor::register_builtin_tools(&self.registry, None);
628        self
629    }
630
631    /// Registers a specific filesystem tool by name
632    pub fn with_filesystem_tool(self, name: &str) -> Result<Self, ToolError> {
633        match name {
634            "Read" => self.registry.register(ReadTool::new()),
635            "Write" => self.registry.register(WriteTool::new()),
636            // apply_patch is now an alias for Edit
637            "Edit" | "apply_patch" => self.registry.register(EditTool::new()),
638            "NotebookEdit" => self.registry.register(NotebookEditTool::new()),
639            _ => return Err(ToolError::NotFound(format!("Unknown tool: {}", name))),
640        }
641        .map_err(|e| ToolError::Execution(e.to_string()))?;
642        Ok(self)
643    }
644
645    /// Registers a specific command tool by name
646    pub fn with_command_tool(self, name: &str) -> Result<Self, ToolError> {
647        match name {
648            "Bash" => self.registry.register(BashTool::new()),
649            "BashOutput" => self.registry.register(BashOutputTool::new()),
650            "KillShell" => self.registry.register(KillShellTool::new()),
651            "Task" => self.registry.register(TaskTool::new()),
652            _ => return Err(ToolError::NotFound(format!("Unknown tool: {}", name))),
653        }
654        .map_err(|e| ToolError::Execution(e.to_string()))?;
655        Ok(self)
656    }
657
658    /// Registers a custom tool
659    pub fn with_tool<T: Tool + 'static>(self, tool: T) -> Result<Self, ToolError> {
660        self.registry
661            .register(tool)
662            .map_err(|e| ToolError::Execution(e.to_string()))?;
663        Ok(self)
664    }
665
666    /// Sets a permission checker for this executor
667    pub fn with_permission_checker(mut self, checker: Arc<dyn PermissionChecker>) -> Self {
668        self.permission_checker = Some(checker);
669        self
670    }
671
672    /// Builds the executor
673    pub fn build(self) -> BuiltinToolExecutor {
674        BuiltinToolExecutor {
675            registry: self.registry,
676            permission_checker: self.permission_checker,
677        }
678    }
679}
680
681impl Default for BuiltinToolExecutorBuilder {
682    fn default() -> Self {
683        Self::new()
684    }
685}
686
687#[cfg(test)]
688mod tests {
689    use super::*;
690    use bamboo_agent_core::AgentEvent;
691    use bamboo_agent_core::FunctionCall;
692    use bamboo_agent_core::ToolCtx;
693    use bamboo_agent_core::ToolExecutionContext;
694    use bamboo_domain::tool_names::{normalize_tool_ref, BUILTIN_TOOL_NAMES};
695    use serde_json::json;
696    use std::sync::Arc;
697    use tokio::fs;
698    use tokio::sync::mpsc;
699
700    use crate::tools::WriteTool;
701
702    fn make_tool_call(name: &str, args: serde_json::Value) -> ToolCall {
703        ToolCall {
704            id: "call_1".to_string(),
705            tool_type: "function".to_string(),
706            function: FunctionCall {
707                name: name.to_string(),
708                arguments: args.to_string(),
709            },
710        }
711    }
712
713    fn make_tool_call_with_raw_args(name: &str, raw_args: &str) -> ToolCall {
714        ToolCall {
715            id: "call_1".to_string(),
716            tool_type: "function".to_string(),
717            function: FunctionCall {
718                name: name.to_string(),
719                arguments: raw_args.to_string(),
720            },
721        }
722    }
723
724    fn make_executor(
725        permission_checker: Option<Arc<dyn PermissionChecker>>,
726    ) -> BuiltinToolExecutor {
727        let builder = BuiltinToolExecutorBuilder::new()
728            .with_tool(WriteTool::new())
729            .expect("register Write tool");
730
731        let builder = match permission_checker {
732            Some(checker) => builder.with_permission_checker(checker),
733            None => builder,
734        };
735
736        builder.build()
737    }
738
739    #[test]
740    fn test_normalize_tool_ref_accepts_claude_style_names() {
741        assert_eq!(
742            normalize_tool_ref("default::Bash"),
743            Some("Bash".to_string())
744        );
745    }
746
747    #[test]
748    fn test_normalize_tool_ref_accepts_legacy_camel_aliases() {
749        assert_eq!(
750            normalize_tool_ref("default::fileExists"),
751            Some("FileExists".to_string())
752        );
753        assert_eq!(
754            normalize_tool_ref("default::getCurrentDir"),
755            Some("GetCurrentDir".to_string())
756        );
757        assert_eq!(
758            normalize_tool_ref("default::getFileInfo"),
759            Some("GetFileInfo".to_string())
760        );
761        assert_eq!(
762            normalize_tool_ref("default::setWorkspace"),
763            Some("SetWorkspace".to_string())
764        );
765        assert_eq!(
766            normalize_tool_ref("default::sleep"),
767            Some("Sleep".to_string())
768        );
769    }
770
771    #[test]
772    fn test_normalize_tool_ref_accepts_legacy_snake_case_aliases() {
773        assert_eq!(
774            normalize_tool_ref("default::execute_command"),
775            Some("Bash".to_string())
776        );
777        assert_eq!(
778            normalize_tool_ref("default::file_exists"),
779            Some("FileExists".to_string())
780        );
781        assert_eq!(
782            normalize_tool_ref("default::get_current_dir"),
783            Some("GetCurrentDir".to_string())
784        );
785        assert_eq!(
786            normalize_tool_ref("default::get_file_info"),
787            Some("GetFileInfo".to_string())
788        );
789        assert_eq!(
790            normalize_tool_ref("default::list_directory"),
791            Some("Glob".to_string())
792        );
793        assert_eq!(
794            normalize_tool_ref("default::memory_note"),
795            Some("memory_note".to_string())
796        );
797        assert_eq!(
798            normalize_tool_ref("default::read_file"),
799            Some("Read".to_string())
800        );
801        assert_eq!(
802            normalize_tool_ref("default::set_workspace"),
803            Some("SetWorkspace".to_string())
804        );
805        assert_eq!(
806            normalize_tool_ref("default::write_file"),
807            Some("Write".to_string())
808        );
809    }
810
811    #[test]
812    fn test_normalize_tool_ref_accepts_spawn_task_aliases() {
813        for alias in [
814            "default::spawn_session",
815            "default::sub_session",
816            "default::sub_task",
817            "default::team_agent",
818            "default::child_session",
819        ] {
820            assert_eq!(normalize_tool_ref(alias), Some("SubAgent".to_string()));
821        }
822    }
823
824    #[test]
825    fn test_normalize_tool_ref_accepts_server_overlay_tools() {
826        assert_eq!(normalize_tool_ref("compress_context"), None);
827        assert_eq!(
828            normalize_tool_ref("default::read_skill_resource"),
829            Some("read_skill_resource".to_string())
830        );
831    }
832
833    #[tokio::test]
834    async fn test_executor_accepts_legacy_read_file_path_argument() {
835        let dir = tempfile::tempdir().unwrap();
836        let file_path = dir.path().join("legacy-read.txt");
837        fs::write(&file_path, "legacy read content").await.unwrap();
838
839        let executor = BuiltinToolExecutor::new();
840        let call = make_tool_call("read_file", json!({"path": file_path}));
841
842        let result = executor.execute(&call).await.unwrap();
843        assert!(result.success);
844        assert!(result.result.contains("legacy read content"));
845    }
846
847    #[tokio::test]
848    async fn test_executor_accepts_legacy_list_directory_without_pattern() {
849        let dir = tempfile::tempdir().unwrap();
850        let file_path = dir.path().join("legacy-list.txt");
851        fs::write(&file_path, "legacy list content").await.unwrap();
852
853        let executor = BuiltinToolExecutor::new();
854        let call = make_tool_call("list_directory", json!({"path": dir.path()}));
855
856        let result = executor.execute(&call).await.unwrap();
857        assert!(result.success);
858        assert!(result.result.contains("legacy-list.txt"));
859    }
860
861    #[tokio::test]
862    async fn test_executor_accepts_canonical_read_with_path_argument() {
863        let dir = tempfile::tempdir().unwrap();
864        let file_path = dir.path().join("canonical-read.txt");
865        fs::write(&file_path, "canonical read content")
866            .await
867            .unwrap();
868
869        let executor = BuiltinToolExecutor::new();
870        let call = make_tool_call("Read", json!({"path": file_path}));
871
872        let result = executor.execute(&call).await.unwrap();
873        assert!(result.success);
874        assert!(result.result.contains("canonical read content"));
875    }
876
877    #[tokio::test]
878    async fn test_executor_accepts_canonical_glob_without_pattern_when_path_present() {
879        let dir = tempfile::tempdir().unwrap();
880        let file_path = dir.path().join("canonical-list.txt");
881        fs::write(&file_path, "canonical list content")
882            .await
883            .unwrap();
884
885        let executor = BuiltinToolExecutor::new();
886        let call = make_tool_call("Glob", json!({"path": dir.path()}));
887
888        let result = executor.execute(&call).await.unwrap();
889        assert!(result.success);
890        assert!(result.result.contains("canonical-list.txt"));
891    }
892
893    #[test]
894    fn test_executor_workspace_mutability_depends_on_path_argument() {
895        let executor = BuiltinToolExecutor::new();
896        let get_call = make_tool_call("Workspace", json!({}));
897        let set_call = make_tool_call("Workspace", json!({"path": "/tmp"}));
898
899        assert_eq!(
900            executor.call_mutability(&get_call),
901            crate::ToolMutability::ReadOnly
902        );
903        assert!(executor.call_concurrency_safe(&get_call));
904
905        assert_eq!(
906            executor.call_mutability(&set_call),
907            crate::ToolMutability::Mutating
908        );
909        assert!(!executor.call_concurrency_safe(&set_call));
910    }
911
912    #[test]
913    fn call_parallel_classification_matches_individual_methods() {
914        // Regression guard for the issue #17 perf refactor: the combined
915        // `call_parallel_classification` (which parses args once) must return the
916        // exact same (mutability, concurrency_safe) pair as calling
917        // `call_mutability` and `call_concurrency_safe` separately (which each
918        // parse args). Covers a read-only tool, mutating tools, and an
919        // args-aware tool (Workspace get vs set) so every branch of the
920        // single-parse override is exercised.
921        let executor = BuiltinToolExecutor::new();
922        let cases: &[(&str, serde_json::Value)] = &[
923            ("Read", json!({})),
924            ("Grep", json!({"pattern": "x"})),
925            (
926                "Write",
927                json!({"file_path": "/tmp/par_cls.txt", "content": "y"}),
928            ),
929            ("Bash", json!({"command": "echo hi"})),
930            ("Workspace", json!({})),
931            ("Workspace", json!({"path": "/tmp"})),
932        ];
933
934        for (name, args) in cases {
935            let call = make_tool_call(name, args.clone());
936            let expected_mutability = executor.call_mutability(&call);
937            let expected_concurrency = executor.call_concurrency_safe(&call);
938            let (mutability, concurrency) = executor.call_parallel_classification(&call);
939            assert_eq!(
940                mutability, expected_mutability,
941                "mutability mismatch for {name} ({args})"
942            );
943            assert_eq!(
944                concurrency, expected_concurrency,
945                "concurrency mismatch for {name} ({args})"
946            );
947        }
948    }
949
950    #[test]
951    fn list_tools_snapshot_is_stable_across_calls() {
952        // The per-round schema cache (issue #17 Part A) assumes the executor's
953        // `list_tools()` is stable within a round: a snapshot taken once must
954        // equal a fresh call. Guards that invariant so caching the set for the
955        // duration of a round can't serve a stale or filtered view.
956        let executor = BuiltinToolExecutor::new();
957        let first: Vec<String> = executor
958            .list_tools()
959            .into_iter()
960            .map(|s| s.function.name)
961            .collect();
962        let second: Vec<String> = executor
963            .list_tools()
964            .into_iter()
965            .map(|s| s.function.name)
966            .collect();
967        assert!(!first.is_empty(), "builtin executor should expose tools");
968        assert_eq!(
969            first, second,
970            "list_tools() must be deterministic per round"
971        );
972    }
973
974    #[tokio::test]
975    async fn test_executor_recovers_truncated_json_arguments() {
976        let dir = tempfile::tempdir().unwrap();
977        let path = dir.path().join("recovered-write.txt");
978
979        // Missing closing brace simulates EOF while parsing an object.
980        let malformed_args = format!(
981            r#"{{"file_path":"{}","content":"recovered content""#,
982            path.display()
983        );
984
985        let executor = BuiltinToolExecutor::new();
986        let call = make_tool_call_with_raw_args("Write", &malformed_args);
987
988        let result = executor
989            .execute(&call)
990            .await
991            .expect("truncated JSON should be auto-repaired");
992        assert!(result.success);
993
994        let written = fs::read_to_string(&path)
995            .await
996            .expect("file should be written");
997        assert_eq!(written, "recovered content");
998    }
999
1000    #[test]
1001    fn test_normalize_tool_ref_rejects_unknown_tool() {
1002        assert_eq!(normalize_tool_ref("default::search"), None);
1003    }
1004
1005    #[test]
1006    fn test_executor_does_not_expose_legacy_tools() {
1007        let executor = BuiltinToolExecutor::new();
1008        let tool_names: Vec<String> = executor
1009            .list_tools()
1010            .into_iter()
1011            .map(|schema| schema.function.name)
1012            .collect();
1013
1014        for legacy in ["claude_code", "search_in_file", "search_in_project"] {
1015            assert!(!tool_names.iter().any(|name| name == legacy));
1016        }
1017    }
1018
1019    #[test]
1020    fn test_critical_tool_schemas_match_claude_shapes() {
1021        let executor = BuiltinToolExecutor::new();
1022        let tools = executor.list_tools();
1023
1024        let get_params = |name: &str| {
1025            tools
1026                .iter()
1027                .find(|tool| tool.function.name == name)
1028                .unwrap()
1029                .function
1030                .parameters
1031                .clone()
1032        };
1033
1034        let grep = get_params("Grep");
1035        assert_eq!(grep["required"], json!(["pattern"]));
1036        assert_eq!(
1037            grep["properties"]["output_mode"]["enum"],
1038            json!(["content", "files_with_matches", "count"])
1039        );
1040        assert!(grep["properties"]["-A"].is_object());
1041        assert!(grep["properties"]["-B"].is_object());
1042        assert!(grep["properties"]["-C"].is_object());
1043        assert!(grep["properties"]["-n"].is_object());
1044        assert!(grep["properties"]["-i"].is_object());
1045
1046        let edit = get_params("Edit");
1047        assert_eq!(edit["required"], json!(["file_path"]));
1048        assert_eq!(edit["properties"]["old_string"]["type"], "string");
1049        assert_eq!(edit["properties"]["new_string"]["type"], "string");
1050        assert_eq!(edit["properties"]["patch"]["type"], "string");
1051        assert_eq!(edit["properties"]["replace_all"]["type"], "boolean");
1052        assert!(edit.get("oneOf").is_none());
1053
1054        // apply_patch is now an alias for Edit – its schema is the Edit
1055        // schema, so we just verify that Edit includes the patch property.
1056        assert_eq!(edit["properties"]["patch"]["type"], "string");
1057        assert_eq!(edit["properties"]["line_number"]["type"], "integer");
1058
1059        let bash = get_params("Bash");
1060        assert_eq!(bash["required"], json!(["command"]));
1061        assert_eq!(bash["properties"]["run_in_background"]["type"], "boolean");
1062        assert_eq!(bash["properties"]["workdir"]["type"], "string");
1063
1064        let bash_output = get_params("BashOutput");
1065        assert_eq!(bash_output["required"], json!(["bash_id"]));
1066        assert_eq!(bash_output["properties"]["filter"]["type"], "string");
1067    }
1068
1069    #[test]
1070    fn test_tool_schemas_avoid_openai_forbidden_top_level_keywords() {
1071        let executor = BuiltinToolExecutor::new();
1072        let tools = executor.list_tools();
1073        let forbidden = ["oneOf", "anyOf", "allOf", "not", "enum"];
1074
1075        for tool in tools {
1076            let params = &tool.function.parameters;
1077            assert_eq!(
1078                params["type"], "object",
1079                "tool '{}' parameters must be a top-level object schema",
1080                tool.function.name
1081            );
1082            for key in forbidden {
1083                assert!(
1084                    params.get(key).is_none(),
1085                    "tool '{}' parameters contains forbidden top-level keyword '{}'",
1086                    tool.function.name,
1087                    key
1088                );
1089            }
1090        }
1091    }
1092
1093    #[test]
1094    fn test_executor_has_all_builtin_tools() {
1095        let executor = BuiltinToolExecutor::new();
1096        let tools = executor.list_tools();
1097
1098        assert_eq!(tools.len(), BUILTIN_TOOL_NAMES.len());
1099
1100        let tool_names: Vec<String> = tools.iter().map(|t| t.function.name.clone()).collect();
1101        for tool_name in BUILTIN_TOOL_NAMES {
1102            assert!(tool_names.contains(&tool_name.to_string()));
1103        }
1104    }
1105
1106    #[test]
1107    fn test_executor_builds_enhanced_prompt() {
1108        let executor = BuiltinToolExecutor::new();
1109        let prompt = executor.build_enhanced_prompt(GuideBuildContext::default());
1110        assert!(prompt.contains("## Tool Usage Guidelines"));
1111        assert!(prompt.contains("**Read**"));
1112    }
1113
1114    #[test]
1115    fn test_executor_builder_empty() {
1116        let executor = BuiltinToolExecutorBuilder::new().build();
1117        assert!(executor.list_tools().is_empty());
1118    }
1119
1120    #[test]
1121    fn test_executor_builder_with_default_tools() {
1122        let executor = BuiltinToolExecutorBuilder::new()
1123            .with_default_tools()
1124            .build();
1125        assert_eq!(executor.list_tools().len(), BUILTIN_TOOL_NAMES.len());
1126    }
1127
1128    #[test]
1129    fn test_executor_builder_with_specific_tool() {
1130        let executor = BuiltinToolExecutorBuilder::new()
1131            .with_filesystem_tool("Read")
1132            .unwrap()
1133            .build();
1134
1135        let tools = executor.list_tools();
1136        assert_eq!(tools.len(), 1);
1137        assert_eq!(tools[0].function.name, "Read");
1138    }
1139
1140    #[tokio::test]
1141    async fn test_executor_skips_permission_checks_without_checker() {
1142        let executor = make_executor(None);
1143        let path = "/tmp/executor_permission_none.txt";
1144        let _ = fs::remove_file(path).await;
1145
1146        let call = make_tool_call("Write", json!({"file_path": path, "content": "ok"}));
1147        let result = executor.execute(&call).await.expect("execute tool");
1148
1149        assert!(result.success);
1150        let _ = fs::remove_file(path).await;
1151    }
1152
1153    #[tokio::test]
1154    async fn test_executor_with_permission_checker_enforces_checks() {
1155        let checker = Arc::new(crate::permission::DenyDangerousPermissionChecker);
1156        let executor = make_executor(Some(checker));
1157        let path = "/tmp/executor_permission_denied.txt";
1158        let _ = fs::remove_file(path).await;
1159
1160        let call = make_tool_call("Write", json!({"file_path": path, "content": "nope"}));
1161        let result = executor.execute(&call).await;
1162
1163        assert!(matches!(result, Err(ToolError::Execution(_))));
1164        assert!(fs::metadata(path).await.is_err());
1165    }
1166
1167    #[tokio::test]
1168    async fn test_bypass_permissions_skips_checker() {
1169        // Even with a deny-all checker, a context flagged `bypass_permissions`
1170        // must skip the permission check entirely and let the write through.
1171        let checker = Arc::new(crate::permission::DenyDangerousPermissionChecker);
1172        let executor = make_executor(Some(checker));
1173        let dir = tempfile::tempdir().unwrap();
1174        let path = dir.path().join("bypass_allows_write.txt");
1175        let path_str = path.to_str().unwrap();
1176
1177        let call = make_tool_call("Write", json!({"file_path": path_str, "content": "ok"}));
1178        let ctx = ToolExecutionContext {
1179            session_id: Some("s-bypass"),
1180            tool_call_id: &call.id,
1181            event_tx: None,
1182            available_tool_schemas: None,
1183            bypass_permissions: true,
1184            can_async_resume: false,
1185            bash_completion_sink: None,
1186            pre_parsed_args: None,
1187        };
1188        let result = executor.execute_with_context(&call, ctx).await;
1189
1190        assert!(result.is_ok(), "bypass should allow the write: {result:?}");
1191        assert_eq!(fs::read_to_string(&path).await.unwrap(), "ok");
1192    }
1193
1194    #[tokio::test]
1195    async fn test_forced_ask_rule_overrides_bypass() {
1196        // A configured "always ask" rule must force a confirmation even when the
1197        // session is in bypass mode. With no event sink the executor fails closed
1198        // (approval required) rather than silently writing.
1199        let config = Arc::new(crate::permission::PermissionConfig::new());
1200        config.set_ask_rules(["Write(/etc/**)".to_string()]);
1201        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1202        let executor = make_executor(Some(checker));
1203
1204        let call = make_tool_call(
1205            "Write",
1206            json!({"file_path": "/etc/forced.conf", "content": "x"}),
1207        );
1208        let ctx = ToolExecutionContext {
1209            session_id: Some("s-forced"),
1210            tool_call_id: &call.id,
1211            event_tx: None,
1212            available_tool_schemas: None,
1213            bypass_permissions: true,
1214            can_async_resume: false,
1215            bash_completion_sink: None,
1216            pre_parsed_args: None,
1217        };
1218        let result = executor.execute_with_context(&call, ctx).await;
1219
1220        assert!(
1221            matches!(result, Err(ToolError::Execution(ref m)) if m.contains("approval required")),
1222            "forced ask rule should block under bypass: {result:?}"
1223        );
1224        assert!(fs::metadata("/etc/forced.conf").await.is_err());
1225    }
1226
1227    #[tokio::test]
1228    async fn test_explicit_deny_overrides_bypass() {
1229        let dir = tempfile::tempdir().unwrap();
1230        let path = dir.path().join("explicit-deny.txt");
1231        let path_str = path.to_str().unwrap();
1232        let config = Arc::new(crate::permission::PermissionConfig::new());
1233        config.add_rule(crate::permission::PermissionRule::new(
1234            crate::permission::PermissionType::WriteFile,
1235            path_str,
1236            false,
1237        ));
1238        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1239        let executor = make_executor(Some(checker));
1240        let call = make_tool_call(
1241            "Write",
1242            json!({"file_path": path_str, "content": "blocked"}),
1243        );
1244        let ctx = ToolExecutionContext {
1245            session_id: Some("s-explicit-deny"),
1246            tool_call_id: &call.id,
1247            event_tx: None,
1248            available_tool_schemas: None,
1249            bypass_permissions: true,
1250            can_async_resume: false,
1251            bash_completion_sink: None,
1252            pre_parsed_args: None,
1253        };
1254
1255        let result = executor.execute_with_context(&call, ctx).await;
1256        assert!(
1257            matches!(result, Err(ToolError::Execution(ref message)) if message.contains("explicit policy")),
1258            "explicit deny must beat bypass: {result:?}"
1259        );
1260        assert!(!path.exists());
1261    }
1262
1263    #[tokio::test]
1264    async fn test_explicit_delete_deny_overrides_bypass() {
1265        let config = Arc::new(crate::permission::PermissionConfig::new());
1266        config.add_rule(crate::permission::PermissionRule::new(
1267            crate::permission::PermissionType::DeleteOperation,
1268            "rm child-to-preserve",
1269            false,
1270        ));
1271        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1272        let executor = BuiltinToolExecutorBuilder::new()
1273            .with_tool(BashTool::new())
1274            .expect("register Bash tool")
1275            .with_permission_checker(checker)
1276            .build();
1277        let call = make_tool_call("Bash", json!({"command": "rm child-to-preserve"}));
1278        let ctx = ToolExecutionContext {
1279            session_id: Some("s-explicit-delete-deny"),
1280            tool_call_id: &call.id,
1281            event_tx: None,
1282            available_tool_schemas: None,
1283            bypass_permissions: true,
1284            can_async_resume: false,
1285            bash_completion_sink: None,
1286            pre_parsed_args: None,
1287        };
1288
1289        let result = executor.execute_with_context(&call, ctx).await;
1290        assert!(
1291            matches!(result, Err(ToolError::Execution(ref message)) if message.contains("explicit policy")),
1292            "explicit delete deny must beat bypass: {result:?}"
1293        );
1294    }
1295
1296    #[tokio::test]
1297    async fn interactive_gate_returns_synthesized_approval_pause() {
1298        // With an event sink present, a forced-ask rule that yields
1299        // `ConfirmationRequired` must resolve to the synthesized "awaiting
1300        // approval" PAUSE result (a `Completed` result tagged
1301        // `display_preference = "request_permissions"`) — NOT an error — so the
1302        // engine turns it into a clarification pause. This locks in the
1303        // interactive-sink path that the `check_permissions_for` extraction must
1304        // preserve as `Ok(Some(outcome))` rather than collapse to an `Err`.
1305        let config = Arc::new(crate::permission::PermissionConfig::new());
1306        config.set_ask_rules(["Write(/etc/**)".to_string()]);
1307        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1308        let executor = make_executor(Some(checker));
1309
1310        let (tx, mut rx) = mpsc::channel(8);
1311        let call = make_tool_call(
1312            "Write",
1313            json!({"file_path": "/etc/gated.conf", "content": "x"}),
1314        );
1315        let ctx = ToolExecutionContext {
1316            session_id: Some("s-interactive"),
1317            tool_call_id: &call.id,
1318            event_tx: Some(&tx),
1319            available_tool_schemas: None,
1320            bypass_permissions: false,
1321            can_async_resume: false,
1322            bash_completion_sink: None,
1323            pre_parsed_args: None,
1324        };
1325
1326        let result = executor
1327            .execute_with_context(&call, ctx)
1328            .await
1329            .expect("interactive gate should pause (Ok), not error");
1330
1331        assert_eq!(
1332            result.display_preference.as_deref(),
1333            Some("request_permissions"),
1334            "interactive gate must return the request_permissions pause result"
1335        );
1336        assert!(result.result.contains("awaiting_permission_approval"));
1337        let payload: serde_json::Value = serde_json::from_str(&result.result).expect("payload");
1338        let request = &payload["permission_request"];
1339        assert_eq!(request["request_id"], call.id);
1340        assert_eq!(request["session_id"], "s-interactive");
1341        assert_eq!(request["reason_code"], "configured_always_ask");
1342        assert_eq!(
1343            request["allowed_decisions"],
1344            json!(["allow_once", "deny_once"])
1345        );
1346        assert_eq!(payload["options"], json!(["Approve", "Deny"]));
1347        assert!(fs::metadata("/etc/gated.conf").await.is_err());
1348
1349        let ev = rx.recv().await.expect("approval event should be emitted");
1350        assert!(
1351            matches!(ev, AgentEvent::ToolApprovalRequested { tool_name, .. } if tool_name == "Write")
1352        );
1353    }
1354
1355    #[tokio::test]
1356    async fn check_permissions_for_returns_none_when_permitted() {
1357        // A tool with no matching gate (Read, no checker rule) passes the gate:
1358        // `check_permissions_for` returns `Ok(None)` so the caller runs the tool.
1359        let executor = make_executor(None);
1360        let call = make_tool_call("Read", json!({"file_path": "/tmp/whatever"}));
1361        let ctx = ToolExecutionContext::none(&call.id);
1362        let decision = executor
1363            .check_permissions_for(&call, &ctx)
1364            .await
1365            .expect("no checker means no gate");
1366        assert!(decision.is_none(), "no checker must yield Ok(None)");
1367    }
1368
1369    // ---- Phase 2: cross-process approval proxy ----------------------------
1370
1371    struct HostStub {
1372        approve: bool,
1373    }
1374
1375    #[async_trait]
1376    impl crate::approval::ApprovalProxy for HostStub {
1377        async fn request_approval(&self, _ask: crate::approval::ApprovalAsk) -> bool {
1378            self.approve
1379        }
1380    }
1381
1382    #[tokio::test]
1383    async fn approval_proxy_grant_lets_gated_tool_proceed() {
1384        // A subagent worker installs an ApprovalProxy for its run. A forced-ask
1385        // rule with NO event sink would otherwise fail closed; with the host
1386        // proxy granting, the executor treats the context as approved and the
1387        // tool proceeds inline (no suspend, no synthetic pause).
1388        let dir = tempfile::tempdir().unwrap();
1389        let path = dir.path().join("approved.txt");
1390        let path_str = path.to_str().unwrap().to_string();
1391        let config = Arc::new(crate::permission::PermissionConfig::new());
1392        config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
1393        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1394        let executor = make_executor(Some(checker));
1395
1396        let call = make_tool_call("Write", json!({"file_path": path_str, "content": "ok"}));
1397        let ctx = ToolExecutionContext {
1398            session_id: Some("s-worker"),
1399            tool_call_id: &call.id,
1400            event_tx: None,
1401            available_tool_schemas: None,
1402            bypass_permissions: false,
1403            can_async_resume: false,
1404            bash_completion_sink: None,
1405            pre_parsed_args: None,
1406        };
1407
1408        let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(HostStub { approve: true });
1409        let result = crate::approval::with_approval_proxy(
1410            Some(proxy),
1411            executor.execute_with_context(&call, ctx),
1412        )
1413        .await;
1414
1415        assert!(
1416            result.is_ok(),
1417            "host grant should let the write through: {result:?}"
1418        );
1419        assert_eq!(fs::read_to_string(&path).await.unwrap(), "ok");
1420    }
1421
1422    #[tokio::test]
1423    async fn approval_proxy_deny_fails_gated_tool_closed() {
1424        // With the host proxy denying, the gated tool fails closed and the side
1425        // effect never happens.
1426        let dir = tempfile::tempdir().unwrap();
1427        let path = dir.path().join("denied.txt");
1428        let path_str = path.to_str().unwrap().to_string();
1429        let config = Arc::new(crate::permission::PermissionConfig::new());
1430        config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
1431        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1432        let executor = make_executor(Some(checker));
1433
1434        let call = make_tool_call("Write", json!({"file_path": path_str, "content": "nope"}));
1435        let ctx = ToolExecutionContext {
1436            session_id: Some("s-worker"),
1437            tool_call_id: &call.id,
1438            event_tx: None,
1439            available_tool_schemas: None,
1440            bypass_permissions: false,
1441            can_async_resume: false,
1442            bash_completion_sink: None,
1443            pre_parsed_args: None,
1444        };
1445
1446        let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(HostStub { approve: false });
1447        let result = crate::approval::with_approval_proxy(
1448            Some(proxy),
1449            executor.execute_with_context(&call, ctx),
1450        )
1451        .await;
1452
1453        assert!(
1454            matches!(result, Err(ToolError::Execution(ref m)) if m.contains("denied by host")),
1455            "host deny should fail the tool closed: {result:?}"
1456        );
1457        assert!(fs::metadata(&path).await.is_err());
1458    }
1459
1460    #[tokio::test]
1461    async fn tool_can_stream_events_via_execute_with_context() {
1462        struct StreamingTool;
1463
1464        #[async_trait]
1465        impl Tool for StreamingTool {
1466            fn name(&self) -> &str {
1467                "streaming_tool"
1468            }
1469
1470            fn description(&self) -> &str {
1471                "streams one token"
1472            }
1473
1474            fn parameters_schema(&self) -> serde_json::Value {
1475                json!({"type":"object","properties":{}})
1476            }
1477
1478            async fn invoke(
1479                &self,
1480                _args: serde_json::Value,
1481                ctx: ToolCtx,
1482            ) -> Result<ToolOutcome, ToolError> {
1483                ctx.emit(AgentEvent::Token {
1484                    content: "stream".to_string(),
1485                })
1486                .await;
1487                Ok(ToolOutcome::Completed(ToolResult {
1488                    success: true,
1489                    result: "ok".to_string(),
1490                    display_preference: None,
1491                    images: Vec::new(),
1492                }))
1493            }
1494        }
1495
1496        let executor = BuiltinToolExecutor::new();
1497        executor
1498            .register_tool(StreamingTool)
1499            .expect("register streaming tool");
1500
1501        let (tx, mut rx) = mpsc::channel(8);
1502        let call = make_tool_call("streaming_tool", json!({}));
1503
1504        let result = executor
1505            .execute_with_context(
1506                &call,
1507                ToolExecutionContext {
1508                    session_id: Some("s1"),
1509                    tool_call_id: &call.id,
1510                    event_tx: Some(&tx),
1511                    available_tool_schemas: None,
1512                    bypass_permissions: false,
1513                    can_async_resume: false,
1514                    bash_completion_sink: None,
1515                    pre_parsed_args: None,
1516                },
1517            )
1518            .await
1519            .expect("execute tool");
1520
1521        assert!(result.success);
1522        assert_eq!(result.result, "ok");
1523
1524        let ev = rx.recv().await.expect("expected streamed event");
1525        assert!(
1526            matches!(ev, AgentEvent::ToolToken { tool_call_id, content } if tool_call_id == "call_1" && content == "stream")
1527        );
1528    }
1529
1530    #[tokio::test]
1531    async fn removed_legacy_tools_return_not_found() {
1532        let executor = BuiltinToolExecutor::new();
1533
1534        for legacy in ["claude_code", "search_in_file", "search_in_project"] {
1535            let call = make_tool_call(legacy, json!({}));
1536            let result = executor.execute(&call).await;
1537            assert!(matches!(result, Err(ToolError::NotFound(_))));
1538        }
1539    }
1540
1541    #[tokio::test]
1542    async fn executor_prefers_exact_tool_name_before_builtin_alias() {
1543        struct CustomSpawnSessionTool;
1544
1545        #[async_trait]
1546        impl Tool for CustomSpawnSessionTool {
1547            fn name(&self) -> &str {
1548                "spawn_session"
1549            }
1550
1551            fn description(&self) -> &str {
1552                "custom tool for regression coverage"
1553            }
1554
1555            fn parameters_schema(&self) -> serde_json::Value {
1556                json!({"type":"object","properties":{}})
1557            }
1558
1559            async fn invoke(
1560                &self,
1561                _args: serde_json::Value,
1562                _ctx: ToolCtx,
1563            ) -> Result<ToolOutcome, ToolError> {
1564                Ok(ToolOutcome::Completed(ToolResult {
1565                    success: true,
1566                    result: "custom-spawn-session".to_string(),
1567                    display_preference: None,
1568                    images: Vec::new(),
1569                }))
1570            }
1571        }
1572
1573        let executor = BuiltinToolExecutorBuilder::new()
1574            .with_tool(CustomSpawnSessionTool)
1575            .expect("register custom spawn_session tool")
1576            .build();
1577
1578        let call = make_tool_call("spawn_session", json!({}));
1579        let result = executor.execute(&call).await.expect("execute custom tool");
1580        assert!(result.success);
1581        assert_eq!(result.result, "custom-spawn-session");
1582    }
1583
1584    // ---- issue #106: parse tool args once on the execute path -------------
1585
1586    /// A tool that echoes back the `v` field of the args it was invoked with, so
1587    /// a test can observe *which* parsed value reached the tool.
1588    struct EchoArgsTool;
1589
1590    #[async_trait]
1591    impl Tool for EchoArgsTool {
1592        fn name(&self) -> &str {
1593            "echo_args"
1594        }
1595        fn description(&self) -> &str {
1596            "echoes the `v` arg"
1597        }
1598        fn parameters_schema(&self) -> serde_json::Value {
1599            json!({"type":"object","properties":{"v":{"type":"string"}}})
1600        }
1601        async fn invoke(
1602            &self,
1603            args: serde_json::Value,
1604            _ctx: ToolCtx,
1605        ) -> Result<ToolOutcome, ToolError> {
1606            let v = args
1607                .get("v")
1608                .and_then(serde_json::Value::as_str)
1609                .unwrap_or("<none>")
1610                .to_string();
1611            Ok(ToolOutcome::Completed(ToolResult {
1612                success: true,
1613                result: v,
1614                display_preference: None,
1615                images: Vec::new(),
1616            }))
1617        }
1618    }
1619
1620    fn ctx_with_pre_parsed<'a>(
1621        call_id: &'a str,
1622        pre_parsed: Option<&'a serde_json::Value>,
1623    ) -> ToolExecutionContext<'a> {
1624        ToolExecutionContext {
1625            session_id: Some("s-106"),
1626            tool_call_id: call_id,
1627            event_tx: None,
1628            available_tool_schemas: None,
1629            bypass_permissions: false,
1630            can_async_resume: false,
1631            bash_completion_sink: None,
1632            pre_parsed_args: pre_parsed,
1633        }
1634    }
1635
1636    #[tokio::test]
1637    async fn execute_with_context_reuses_pre_parsed_args_without_reparsing() {
1638        // The raw `arguments` string and the threaded `pre_parsed_args` Value
1639        // deliberately disagree. If the executor honored the contract (parse
1640        // once at the dispatch site, reuse downstream), the tool sees the
1641        // pre-parsed value; if it re-parsed the raw string it would see "raw".
1642        // This is the load-bearing proof that the second parse was eliminated.
1643        let executor = BuiltinToolExecutor::new();
1644        executor.register_tool(EchoArgsTool).expect("register echo");
1645
1646        let call = make_tool_call("echo_args", json!({"v": "raw"}));
1647        let pre_parsed = json!({"v": "preparsed"});
1648        let ctx = ctx_with_pre_parsed(&call.id, Some(&pre_parsed));
1649
1650        let result = executor
1651            .execute_with_context(&call, ctx)
1652            .await
1653            .expect("execute echo tool");
1654        assert_eq!(
1655            result.result, "preparsed",
1656            "executor must reuse pre_parsed_args, not re-parse the raw string"
1657        );
1658    }
1659
1660    #[tokio::test]
1661    async fn execute_with_context_parses_raw_when_no_pre_parsed_args() {
1662        // Without a threaded value (the `execute` entry point / tests / a loop
1663        // that parsed with a different parser), the executor falls back to
1664        // parsing the raw string exactly as before — behavior preserved.
1665        let executor = BuiltinToolExecutor::new();
1666        executor.register_tool(EchoArgsTool).expect("register echo");
1667
1668        let call = make_tool_call("echo_args", json!({"v": "raw"}));
1669        let ctx = ctx_with_pre_parsed(&call.id, None);
1670
1671        let result = executor
1672            .execute_with_context(&call, ctx)
1673            .await
1674            .expect("execute echo tool");
1675        assert_eq!(
1676            result.result, "raw",
1677            "without pre_parsed_args the executor parses the raw string as before"
1678        );
1679    }
1680
1681    #[tokio::test]
1682    async fn execute_with_context_malformed_args_repair_unchanged_without_pre_parsed() {
1683        // Malformed (truncated) JSON must still be auto-repaired by the
1684        // fallback parse when no pre-parsed value is threaded — the existing
1685        // error/leniency behavior is untouched by the dedup.
1686        let dir = tempfile::tempdir().unwrap();
1687        let path = dir.path().join("recovered-no-preparsed.txt");
1688        let malformed_args = format!(
1689            r#"{{"file_path":"{}","content":"recovered content""#,
1690            path.display()
1691        );
1692
1693        let executor = BuiltinToolExecutor::new();
1694        let call = make_tool_call_with_raw_args("Write", &malformed_args);
1695        let ctx = ctx_with_pre_parsed(&call.id, None);
1696
1697        let result = executor
1698            .execute_with_context(&call, ctx)
1699            .await
1700            .expect("truncated JSON should be auto-repaired");
1701        assert!(result.success);
1702        let written = fs::read_to_string(&path).await.expect("file written");
1703        assert_eq!(written, "recovered content");
1704    }
1705}