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