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