Skip to main content

zeph_tools/
executor.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::fmt;
5
6use zeph_common::ToolName;
7
8use crate::shell::background::RunId;
9
10/// Data for rendering file diffs in the TUI.
11///
12/// Produced by [`ShellExecutor`](crate::ShellExecutor) and [`FileExecutor`](crate::FileExecutor)
13/// when a tool call modifies a tracked file. The TUI uses this to display a side-by-side diff.
14#[derive(Debug, Clone)]
15pub struct DiffData {
16    /// Relative or absolute path to the file that was modified.
17    pub file_path: String,
18    /// File content before the tool executed.
19    pub old_content: String,
20    /// File content after the tool executed.
21    pub new_content: String,
22}
23
24/// Structured tool invocation from LLM.
25///
26/// Produced by the agent loop when the LLM emits a structured tool call (as opposed to
27/// a legacy fenced code block). Dispatched to [`ToolExecutor::execute_tool_call`].
28///
29/// # Example
30///
31/// ```rust
32/// use zeph_tools::{ToolCall, ExecutionContext};
33/// use zeph_common::ToolName;
34///
35/// let call = ToolCall {
36///     tool_id: ToolName::new("bash"),
37///     params: {
38///         let mut m = serde_json::Map::new();
39///         m.insert("command".to_owned(), serde_json::Value::String("echo hello".to_owned()));
40///         m
41///     },
42///     caller_id: Some("user-42".to_owned()),
43///     context: Some(ExecutionContext::new().with_name("repo")),
44///     tool_call_id: String::new(),
45///     skill_name: None,
46/// };
47/// assert_eq!(call.tool_id, "bash");
48/// ```
49#[derive(Debug, Clone, Default)]
50pub struct ToolCall {
51    /// The tool identifier, matching a value from [`ToolExecutor::tool_definitions`].
52    pub tool_id: ToolName,
53    /// JSON parameters for the tool call, deserialized into the tool's parameter struct.
54    pub params: serde_json::Map<String, serde_json::Value>,
55    /// Opaque caller identifier propagated from the channel (user ID, session ID, etc.).
56    /// `None` for system-initiated calls (scheduler, self-learning, internal).
57    pub caller_id: Option<String>,
58    /// Per-turn execution environment. `None` means use the executor default (process CWD
59    /// and inherited env), which is identical to the behaviour before this field existed.
60    pub context: Option<crate::ExecutionContext>,
61    /// Opaque tool call ID used to correlate [`ToolEvent::OutputChunk`] events with
62    /// their originating tool call in the TUI. Empty when not set by the agent loop.
63    pub tool_call_id: String,
64    /// Names of skills active in the turn that issued this tool call (turn-level attribution).
65    ///
66    /// This is a best-effort, turn-scoped field: it lists the skills injected into the
67    /// system prompt for the current turn, not the specific skill that caused this individual
68    /// call (the LLM does not report per-call causation). `None` for system-initiated or
69    /// internal tool calls that execute outside the skill-augmented agent loop.
70    pub skill_name: Option<Vec<String>>,
71}
72
73/// Cumulative filter statistics for a single tool execution.
74///
75/// Populated by [`ShellExecutor`](crate::ShellExecutor) when output filters are configured.
76/// Displayed in the TUI to show how much output was compacted before being sent to the LLM.
77#[derive(Debug, Clone, Default)]
78pub struct FilterStats {
79    /// Raw character count before filtering.
80    pub raw_chars: usize,
81    /// Character count after filtering.
82    pub filtered_chars: usize,
83    /// Raw line count before filtering.
84    pub raw_lines: usize,
85    /// Line count after filtering.
86    pub filtered_lines: usize,
87    /// Worst-case confidence across all applied filters.
88    pub confidence: Option<crate::FilterConfidence>,
89    /// The shell command that produced this output, for display purposes.
90    pub command: Option<String>,
91    /// Zero-based line indices that were kept after filtering.
92    pub kept_lines: Vec<usize>,
93}
94
95impl FilterStats {
96    /// Returns the percentage of characters removed by filtering.
97    ///
98    /// Returns `0.0` when there was no raw output to filter.
99    #[must_use]
100    #[allow(clippy::cast_precision_loss)]
101    pub fn savings_pct(&self) -> f64 {
102        if self.raw_chars == 0 {
103            return 0.0;
104        }
105        (1.0 - self.filtered_chars as f64 / self.raw_chars as f64) * 100.0
106    }
107
108    /// Estimates the number of LLM tokens saved by filtering.
109    ///
110    /// Uses the 4-chars-per-token approximation. Suitable for logging and metrics,
111    /// not for billing or exact budget calculations.
112    #[must_use]
113    pub fn estimated_tokens_saved(&self) -> usize {
114        self.raw_chars.saturating_sub(self.filtered_chars) / 4
115    }
116
117    /// Formats a one-line filter summary for log messages and TUI status.
118    ///
119    /// # Example
120    ///
121    /// ```rust
122    /// use zeph_tools::FilterStats;
123    ///
124    /// let stats = FilterStats {
125    ///     raw_chars: 1000,
126    ///     filtered_chars: 400,
127    ///     raw_lines: 50,
128    ///     filtered_lines: 20,
129    ///     command: Some("cargo build".to_owned()),
130    ///     ..Default::default()
131    /// };
132    /// let summary = stats.format_inline("shell");
133    /// assert!(summary.contains("60.0% filtered"));
134    /// ```
135    #[must_use]
136    pub fn format_inline(&self, tool_name: &str) -> String {
137        let cmd_label = self
138            .command
139            .as_deref()
140            .map(|c| {
141                let trimmed = c.trim();
142                if trimmed.len() > 60 {
143                    format!(" `{}…`", &trimmed[..57])
144                } else {
145                    format!(" `{trimmed}`")
146                }
147            })
148            .unwrap_or_default();
149        format!(
150            "[{tool_name}]{cmd_label} {} lines \u{2192} {} lines, {:.1}% filtered",
151            self.raw_lines,
152            self.filtered_lines,
153            self.savings_pct()
154        )
155    }
156}
157
158/// Result returned by checkpoint undo/redo operations.
159///
160/// When `supported` is `false`, the executor does not implement checkpoints; callers
161/// should surface an appropriate user-facing message without treating this as an error.
162#[derive(Debug, Clone, Default)]
163pub struct CheckpointActionResult {
164    /// Number of commands actually reverted or re-applied.
165    pub reverted_commands: usize,
166    /// Number of files restored (written from backup).
167    pub restored: usize,
168    /// Number of files deleted (were absent at capture time).
169    pub deleted: usize,
170    /// `false` when this executor does not support checkpoints.
171    pub supported: bool,
172    /// Human-readable summary of what happened.
173    pub message: String,
174}
175
176impl CheckpointActionResult {
177    /// Sentinel returned by executors that do not implement checkpoints.
178    #[must_use]
179    pub fn unsupported() -> Self {
180        Self {
181            supported: false,
182            message: String::new(),
183            ..Default::default()
184        }
185    }
186}
187
188/// A single undo-stack entry for display in `/undo list`.
189#[derive(Debug, Clone)]
190pub struct CheckpointEntryView {
191    /// Zero-based index (0 = most recent).
192    pub index: usize,
193    /// The shell command that produced this checkpoint.
194    pub command: String,
195    /// Unix timestamp (seconds since epoch) when the checkpoint was recorded.
196    pub captured_at_secs: u64,
197    /// Number of files captured.
198    pub file_count: usize,
199}
200
201/// Result returned by the checkpoint list query.
202#[derive(Debug, Clone, Default)]
203pub struct CheckpointListResult {
204    /// Undo stack entries, most-recent first.
205    pub entries: Vec<CheckpointEntryView>,
206    /// Number of redo entries available.
207    pub redo_depth: usize,
208    /// `false` when this executor does not implement checkpoints.
209    pub supported: bool,
210}
211
212/// Provenance of a tool execution result.
213///
214/// Set by each executor at `ToolOutput` construction time. Used by the sanitizer bridge
215/// in `zeph-core` to select the appropriate `ContentSourceKind` and trust level.
216/// `None` means the source is unspecified (pass-through code, mocks, tests).
217#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
218#[serde(rename_all = "snake_case")]
219#[non_exhaustive]
220pub enum ClaimSource {
221    /// Local shell command execution.
222    Shell,
223    /// Local file system read/write.
224    FileSystem,
225    /// HTTP web scrape.
226    WebScrape,
227    /// MCP server tool response.
228    Mcp,
229    /// A2A agent message.
230    A2a,
231    /// Code search (LSP or semantic).
232    CodeSearch,
233    /// Agent diagnostics (internal).
234    Diagnostics,
235    /// Memory retrieval (semantic search).
236    Memory,
237    /// Telegram moderation action (reaction deletion).
238    Moderation,
239}
240
241/// Structured result from tool execution.
242///
243/// Returned by every [`ToolExecutor`] implementation on success. The agent loop uses
244/// [`ToolOutput::summary`] as the tool result text injected into the LLM context.
245///
246/// # Example
247///
248/// ```rust
249/// use zeph_tools::{ToolOutput, executor::ClaimSource};
250/// use zeph_common::ToolName;
251///
252/// let output = ToolOutput {
253///     tool_name: ToolName::new("shell"),
254///     summary: "hello\n".to_owned(),
255///     blocks_executed: 1,
256///     filter_stats: None,
257///     diff: None,
258///     streamed: false,
259///     terminal_id: None,
260///     locations: None,
261///     raw_response: None,
262///     claim_source: Some(ClaimSource::Shell),
263/// };
264/// assert_eq!(output.to_string(), "hello\n");
265/// ```
266#[derive(Debug, Clone)]
267pub struct ToolOutput {
268    /// Name of the tool that produced this output (e.g. `"shell"`, `"web-scrape"`).
269    pub tool_name: ToolName,
270    /// Human-readable result text injected into the LLM context.
271    pub summary: String,
272    /// Number of code blocks processed in this invocation.
273    pub blocks_executed: u32,
274    /// Output filter statistics when filtering was applied, `None` otherwise.
275    pub filter_stats: Option<FilterStats>,
276    /// File diff data for TUI display when the tool modified a tracked file.
277    pub diff: Option<DiffData>,
278    /// Whether this tool already streamed its output via `ToolEvent` channel.
279    pub streamed: bool,
280    /// Terminal ID when the tool was executed via IDE terminal (ACP terminal/* protocol).
281    pub terminal_id: Option<String>,
282    /// File paths touched by this tool call, for IDE follow-along (e.g. `ToolCallLocation`).
283    pub locations: Option<Vec<String>>,
284    /// Structured tool response payload for ACP intermediate `tool_call_update` notifications.
285    pub raw_response: Option<serde_json::Value>,
286    /// Provenance of this tool result. Set by the executor at construction time.
287    /// `None` in pass-through wrappers, mocks, and tests.
288    pub claim_source: Option<ClaimSource>,
289}
290
291impl fmt::Display for ToolOutput {
292    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
293        f.write_str(&self.summary)
294    }
295}
296
297/// Maximum characters of tool output injected into the LLM context without truncation.
298///
299/// Output that exceeds this limit is split into a head and tail via [`truncate_tool_output`]
300/// to keep both the beginning and end of large command outputs.
301pub const MAX_TOOL_OUTPUT_CHARS: usize = 30_000;
302
303/// Truncate tool output that exceeds [`MAX_TOOL_OUTPUT_CHARS`] using a head+tail split.
304///
305/// Equivalent to `truncate_tool_output_at(output, MAX_TOOL_OUTPUT_CHARS)`.
306///
307/// # Example
308///
309/// ```rust
310/// use zeph_tools::executor::truncate_tool_output;
311///
312/// let short = "hello world";
313/// assert_eq!(truncate_tool_output(short), short);
314/// ```
315#[must_use]
316pub fn truncate_tool_output(output: &str) -> String {
317    truncate_tool_output_at(output, MAX_TOOL_OUTPUT_CHARS)
318}
319
320/// Truncate tool output that exceeds `max_chars` using a head+tail split.
321///
322/// Preserves the first and last `max_chars / 2` characters and inserts a truncation
323/// marker in the middle. Both boundaries are snapped to valid UTF-8 character boundaries.
324///
325/// # Example
326///
327/// ```rust
328/// use zeph_tools::executor::truncate_tool_output_at;
329///
330/// let long = "a".repeat(200);
331/// let truncated = truncate_tool_output_at(&long, 100);
332/// assert!(truncated.contains("truncated"));
333/// assert!(truncated.len() < long.len());
334/// ```
335#[must_use]
336pub fn truncate_tool_output_at(output: &str, max_chars: usize) -> String {
337    if output.len() <= max_chars {
338        return output.to_string();
339    }
340
341    let half = max_chars / 2;
342    let head_end = output.floor_char_boundary(half);
343    let tail_start = output.ceil_char_boundary(output.len() - half);
344    let head = &output[..head_end];
345    let tail = &output[tail_start..];
346    let truncated = output.len() - head_end - (output.len() - tail_start);
347
348    format!(
349        "{head}\n\n... [truncated {truncated} chars, showing first and last ~{half} chars] ...\n\n{tail}"
350    )
351}
352
353/// Event emitted during tool execution for real-time UI updates.
354///
355/// Sent over the [`ToolEventTx`] channel to the TUI or channel adapter.
356/// Each event variant corresponds to a phase in the tool execution lifecycle.
357#[derive(Debug, Clone)]
358#[non_exhaustive]
359pub enum ToolEvent {
360    /// The tool has started. Displayed in the TUI as a spinner with the command text.
361    Started {
362        tool_name: ToolName,
363        command: String,
364        /// Active sandbox profile, if any. `None` when sandbox is disabled.
365        sandbox_profile: Option<String>,
366        /// Canonical absolute working directory the command will run in.
367        /// `None` for executors that do not resolve a per-turn CWD.
368        resolved_cwd: Option<String>,
369        /// Name of the resolved execution environment (from `[[execution.environments]]`),
370        /// or `None` when no named environment was selected.
371        execution_env: Option<String>,
372    },
373    /// A chunk of streaming output was produced (e.g. from a long-running command).
374    OutputChunk {
375        tool_name: ToolName,
376        command: String,
377        chunk: String,
378        /// Opaque tool call ID matching the corresponding [`ToolEvent::Started`] event.
379        /// Empty string when the executor does not have access to the call ID.
380        tool_call_id: String,
381        /// Skills active in the turn that triggered this tool call (turn-level attribution).
382        skill_name: Option<Vec<String>>,
383    },
384    /// The tool finished. Contains the full output and optional filter/diff data.
385    Completed {
386        tool_name: ToolName,
387        command: String,
388        /// Full output text (possibly filtered and truncated).
389        output: String,
390        /// `true` when the tool exited successfully, `false` on error.
391        success: bool,
392        filter_stats: Option<FilterStats>,
393        diff: Option<DiffData>,
394        /// Set when this completion belongs to a background run. `None` for blocking runs.
395        run_id: Option<RunId>,
396    },
397    /// A transactional rollback was performed, restoring or deleting files.
398    Rollback {
399        tool_name: ToolName,
400        command: String,
401        /// Number of files restored to their pre-execution content.
402        restored_count: usize,
403        /// Number of files that did not exist before execution and were deleted.
404        deleted_count: usize,
405    },
406}
407
408/// Sender half of the bounded channel used to stream [`ToolEvent`]s to the UI.
409///
410/// Capacity is 1024 slots. Streaming variants (`OutputChunk`, `Started`) use
411/// `try_send` and drop on full; terminal variants (`Completed`, `Rollback`) use
412/// `send().await` to guarantee delivery.
413///
414/// Created via [`tokio::sync::mpsc::channel`] with capacity `TOOL_EVENT_CHANNEL_CAP`.
415pub type ToolEventTx = tokio::sync::mpsc::Sender<ToolEvent>;
416
417/// Receiver half matching [`ToolEventTx`].
418pub type ToolEventRx = tokio::sync::mpsc::Receiver<ToolEvent>;
419
420/// Bounded capacity for the tool-event channel.
421pub const TOOL_EVENT_CHANNEL_CAP: usize = 1024;
422
423/// Classifies a tool error as transient (retryable) or permanent (abort immediately).
424///
425/// Transient errors may succeed on retry (network blips, race conditions).
426/// Permanent errors will not succeed regardless of retries (policy, bad args, not found).
427#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
428#[non_exhaustive]
429pub enum ErrorKind {
430    Transient,
431    Permanent,
432}
433
434impl std::fmt::Display for ErrorKind {
435    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
436        match self {
437            Self::Transient => f.write_str("transient"),
438            Self::Permanent => f.write_str("permanent"),
439        }
440    }
441}
442
443#[non_exhaustive]
444/// Errors that can occur during tool execution.
445#[derive(Debug, thiserror::Error)]
446pub enum ToolError {
447    #[error("command blocked by policy: {command}")]
448    Blocked { command: String },
449
450    /// Command was blocked and a safer alternative is available.
451    ///
452    /// Emitted by [`ShellExecutor`](crate::ShellExecutor) when `suggest_fix` returns a
453    /// suggestion. The agent receives both the block reason and the alternative so it can
454    /// self-correct without additional prompting.
455    #[error("command blocked by policy: {command}")]
456    BlockedWithFix {
457        command: String,
458        suggestion: Option<crate::shell::SafeFixSuggestion>,
459    },
460
461    #[error("path not allowed by sandbox: {path}")]
462    SandboxViolation { path: String },
463
464    #[error("command requires confirmation: {command}")]
465    ConfirmationRequired { command: String },
466
467    #[error("command timed out after {timeout_secs}s")]
468    Timeout { timeout_secs: u64 },
469
470    #[error("operation cancelled")]
471    Cancelled,
472
473    #[error("invalid tool parameters: {message}")]
474    InvalidParams { message: String },
475
476    #[error("execution failed: {0}")]
477    Execution(#[from] std::io::Error),
478
479    /// HTTP or API error with status code for fine-grained classification.
480    ///
481    /// Used by `WebScrapeExecutor` and other HTTP-based tools to preserve the status
482    /// code for taxonomy classification. Scope: HTTP tools only (MCP uses a separate path).
483    #[error("HTTP error {status}: {message}")]
484    Http { status: u16, message: String },
485
486    /// Shell execution error with explicit exit code and pre-classified category.
487    ///
488    /// Used by `ShellExecutor` when the exit code or stderr content maps to a known
489    /// taxonomy category (e.g., exit 126 → `PolicyBlocked`, exit 127 → `PermanentFailure`).
490    /// Preserves the exit code for audit logging and the category for skill evolution.
491    #[error("shell error (exit {exit_code}): {message}")]
492    Shell {
493        exit_code: i32,
494        category: crate::error_taxonomy::ToolErrorCategory,
495        message: String,
496    },
497
498    #[error("snapshot failed: {reason}")]
499    SnapshotFailed { reason: String },
500
501    /// Tool call rejected because the tool id is outside the active capability scope.
502    ///
503    /// Emitted by `ScopedToolExecutor` before any tool side-effect runs.
504    /// The audit log records `error_category = "out_of_scope"`.
505    // LLM isolation: task_type is never shown in the error message (P2-OutOfScope).
506    #[error("tool call denied by policy")]
507    OutOfScope {
508        /// Fully-qualified tool id that was rejected.
509        tool_id: String,
510        /// Active task type at dispatch time, if any.
511        task_type: Option<String>,
512    },
513
514    /// Tool call blocked by `ShadowProbeExecutor` after the LLM safety probe returned Deny.
515    ///
516    /// Emitted before any tool side-effect runs. The probe evaluated the full trajectory
517    /// context and determined the call is unsafe. Reason is LLM-generated; shown to the
518    /// agent loop as the tool result so the model can adapt.
519    #[error("tool call denied by safety probe: {reason}")]
520    SafetyDenied {
521        /// Human-readable explanation from the LLM safety probe.
522        reason: String,
523    },
524
525    /// Tool call blocked by the MAGE `TrajectoryRiskAccumulator` (spec 004-16).
526    ///
527    /// Cumulative session risk exceeded `risk_threshold`. The agent loop receives the
528    /// score and the top contributing signals so it can explain the denial to the user.
529    #[error("tool call blocked: trajectory risk {score:.3} exceeds threshold")]
530    TrajectoryRiskExceeded {
531        /// Current `trajectory_risk` value at the time of the block.
532        score: f64,
533        /// Human-readable labels for the top contributing signals (up to 3).
534        top_signals: Vec<String>,
535    },
536}
537
538impl ToolError {
539    /// Fine-grained error classification using the 12-category taxonomy.
540    ///
541    /// Prefer `category()` over `kind()` for new code. `kind()` is preserved for
542    /// backward compatibility and delegates to `category().error_kind()`.
543    #[must_use]
544    pub fn category(&self) -> crate::error_taxonomy::ToolErrorCategory {
545        use crate::error_taxonomy::{ToolErrorCategory, classify_http_status, classify_io_error};
546        match self {
547            Self::Blocked { .. } | Self::BlockedWithFix { .. } | Self::SandboxViolation { .. } => {
548                ToolErrorCategory::PolicyBlocked
549            }
550            Self::ConfirmationRequired { .. } => ToolErrorCategory::ConfirmationRequired,
551            Self::Timeout { .. } => ToolErrorCategory::Timeout,
552            Self::Cancelled => ToolErrorCategory::Cancelled,
553            Self::InvalidParams { .. } => ToolErrorCategory::InvalidParameters,
554            Self::Http { status, .. } => classify_http_status(*status),
555            Self::Execution(io_err) => classify_io_error(io_err),
556            Self::Shell { category, .. } => *category,
557            Self::SnapshotFailed { .. } => ToolErrorCategory::PermanentFailure,
558            Self::OutOfScope { .. }
559            | Self::SafetyDenied { .. }
560            | Self::TrajectoryRiskExceeded { .. } => ToolErrorCategory::PolicyBlocked,
561        }
562    }
563
564    /// Coarse classification for backward compatibility. Delegates to `category().error_kind()`.
565    ///
566    /// For `Execution(io::Error)`, the classification inspects `io::Error::kind()`:
567    /// - Transient: `TimedOut`, `WouldBlock`, `Interrupted`, `ConnectionReset`,
568    ///   `ConnectionAborted`, `BrokenPipe` — these may succeed on retry.
569    /// - Permanent: `NotFound`, `PermissionDenied`, `AlreadyExists`, and all other
570    ///   I/O error kinds — retrying would waste time with no benefit.
571    #[must_use]
572    pub fn kind(&self) -> ErrorKind {
573        use crate::error_taxonomy::ToolErrorCategoryExt;
574        self.category().error_kind()
575    }
576}
577
578/// Deserialize tool call params from a `serde_json::Map<String, Value>` into a typed struct.
579///
580/// # Errors
581///
582/// Returns `ToolError::InvalidParams` when deserialization fails.
583pub fn deserialize_params<T: serde::de::DeserializeOwned>(
584    params: &serde_json::Map<String, serde_json::Value>,
585) -> Result<T, ToolError> {
586    let obj = serde_json::Value::Object(params.clone());
587    serde_json::from_value(obj).map_err(|e| ToolError::InvalidParams {
588        message: e.to_string(),
589    })
590}
591
592/// Async trait for tool execution backends.
593///
594/// Implementations include [`ShellExecutor`](crate::ShellExecutor),
595/// [`WebScrapeExecutor`](crate::WebScrapeExecutor), [`CompositeExecutor`](crate::CompositeExecutor),
596/// and [`FileExecutor`](crate::FileExecutor).
597///
598/// # Contract
599///
600/// - [`execute`](ToolExecutor::execute) and [`execute_tool_call`](ToolExecutor::execute_tool_call)
601///   return `Ok(None)` when the executor does not handle the given input — callers must not
602///   treat `None` as an error.
603/// - All methods must be `Send + Sync` and free of blocking I/O.
604/// - Implementations must enforce their own security controls (blocklists, sandboxes, SSRF
605///   protection) before executing any side-effectful operation.
606/// - [`execute_confirmed`](ToolExecutor::execute_confirmed) and
607///   [`execute_tool_call_confirmed`](ToolExecutor::execute_tool_call_confirmed) bypass
608///   confirmation gates only — all other security controls remain active.
609///
610/// # Two Invocation Paths
611///
612/// **Legacy fenced blocks**: The agent loop passes the raw LLM response string to [`execute`](ToolExecutor::execute).
613/// The executor parses ` ```bash ` or ` ```scrape ` blocks and executes each one.
614///
615/// **Structured tool calls**: The agent loop constructs a [`ToolCall`] from the LLM's
616/// JSON tool-use response and dispatches it via [`execute_tool_call`](ToolExecutor::execute_tool_call).
617/// This is the preferred path for new code.
618///
619/// # Example
620///
621/// ```rust
622/// use zeph_tools::{ToolExecutor, ToolCall, ToolOutput, ToolError, executor::ClaimSource};
623///
624/// #[derive(Debug)]
625/// struct EchoExecutor;
626///
627/// impl ToolExecutor for EchoExecutor {
628///     async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
629///         Ok(None) // not a fenced-block executor
630///     }
631///
632///     async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
633///         if call.tool_id != "echo" {
634///             return Ok(None);
635///         }
636///         let text = call.params.get("text")
637///             .and_then(|v| v.as_str())
638///             .unwrap_or("")
639///             .to_owned();
640///         Ok(Some(ToolOutput {
641///             tool_name: "echo".into(),
642///             summary: text,
643///             blocks_executed: 1,
644///             filter_stats: None,
645///             diff: None,
646///             streamed: false,
647///             terminal_id: None,
648///             locations: None,
649///             raw_response: None,
650///             claim_source: None,
651///         }))
652///     }
653/// }
654/// ```
655/// # TODO (G3 — deferred: Tower-style tool middleware stack)
656///
657/// Currently, cross-cutting concerns (audit logging, rate limiting, sandboxing, guardrails)
658/// are scattered across individual executor implementations. The planned approach is a
659/// composable middleware stack similar to Tower's `Service` trait:
660///
661/// ```text
662/// AuditLayer::new(RateLimitLayer::new(SandboxLayer::new(ShellExecutor::new())))
663/// ```
664///
665/// **Blocked by:** requires D2 (consolidating `ToolExecutor` + `ErasedToolExecutor` into one
666/// object-safe trait). See critic review §S3 for the tradeoff between RPIT fast-path and
667/// dynamic dispatch overhead before collapsing D2.
668///
669/// # TODO (D2 — deferred: consolidate `ToolExecutor` and `ErasedToolExecutor`)
670///
671/// Having two parallel traits creates duplication and confusion. The blanket impl
672/// `impl<T: ToolExecutor> ErasedToolExecutor for T` works but every new method must be
673/// added to both traits. Use `trait_variant::make` or a single object-safe design.
674///
675/// **Blocked by:** need to benchmark the RPIT fast-path before removing it. See critic §S3.
676pub trait ToolExecutor: Send + Sync {
677    /// Parse `response` for fenced tool blocks and execute them.
678    ///
679    /// Returns `Ok(None)` when no tool blocks are found in `response`.
680    ///
681    /// # Errors
682    ///
683    /// Returns [`ToolError`] when a block is found but execution fails (blocked command,
684    /// sandbox violation, network error, timeout, etc.).
685    fn execute(
686        &self,
687        response: &str,
688    ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send;
689
690    /// Execute bypassing confirmation checks (called after user approves).
691    ///
692    /// Security controls other than the confirmation gate remain active. Default
693    /// implementation delegates to [`execute`](ToolExecutor::execute).
694    ///
695    /// # Errors
696    ///
697    /// Returns [`ToolError`] on execution failure.
698    fn execute_confirmed(
699        &self,
700        response: &str,
701    ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
702        self.execute(response)
703    }
704
705    /// Return the tool definitions this executor can handle.
706    ///
707    /// Used to populate the LLM's tool schema at context-assembly time.
708    /// Returns an empty `Vec` by default (for executors that only handle fenced blocks).
709    fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
710        vec![]
711    }
712
713    /// Execute a structured tool call. Returns `Ok(None)` if `call.tool_id` is not handled.
714    ///
715    /// # Errors
716    ///
717    /// Returns [`ToolError`] when the tool ID is handled but execution fails.
718    fn execute_tool_call(
719        &self,
720        _call: &ToolCall,
721    ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
722        std::future::ready(Ok(None))
723    }
724
725    /// Execute a structured tool call bypassing confirmation checks.
726    ///
727    /// Called after the user has explicitly approved the tool invocation.
728    /// Default implementation delegates to [`execute_tool_call`](ToolExecutor::execute_tool_call).
729    ///
730    /// # Errors
731    ///
732    /// Returns [`ToolError`] on execution failure.
733    fn execute_tool_call_confirmed(
734        &self,
735        call: &ToolCall,
736    ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
737        self.execute_tool_call(call)
738    }
739
740    /// Inject environment variables for the currently active skill. No-op by default.
741    ///
742    /// Called by the agent loop before each turn when the active skill specifies env vars.
743    /// Implementations that ignore this (e.g. `WebScrapeExecutor`) may leave the default.
744    fn set_skill_env(&self, _env: Option<std::collections::HashMap<String, String>>) {}
745
746    /// Set the effective trust level for the currently active skill. No-op by default.
747    ///
748    /// Trust level affects which operations are permitted (e.g. network access, file writes).
749    fn set_effective_trust(&self, _level: crate::SkillTrustLevel) {}
750
751    /// Whether the executor can safely retry this tool call on a transient error.
752    ///
753    /// Only idempotent operations (e.g. read-only HTTP GET) should return `true`.
754    /// Shell commands and other non-idempotent operations must keep the default `false`
755    /// to prevent double-execution of side-effectful commands.
756    fn is_tool_retryable(&self, _tool_id: &str) -> bool {
757        false
758    }
759
760    /// Undo the last `n` checkpointed write commands.
761    ///
762    /// Returns [`CheckpointActionResult::unsupported`] by default. Executors that
763    /// implement checkpoints (i.e. [`ShellExecutor`](crate::ShellExecutor) with
764    /// `checkpoints_enabled = true`) override this.
765    fn checkpoint_undo(&self, _n: usize) -> CheckpointActionResult {
766        CheckpointActionResult::unsupported()
767    }
768
769    /// Redo the last undone checkpoint.
770    ///
771    /// Returns [`CheckpointActionResult::unsupported`] by default.
772    fn checkpoint_redo(&self) -> CheckpointActionResult {
773        CheckpointActionResult::unsupported()
774    }
775
776    /// List the current undo stack entries and redo depth.
777    ///
778    /// Returns an empty [`CheckpointListResult`] with `supported = false` by default.
779    fn checkpoint_list(&self) -> CheckpointListResult {
780        CheckpointListResult::default()
781    }
782
783    /// Whether a tool call can be safely dispatched speculatively (before the LLM finishes).
784    ///
785    /// Speculative execution requires the tool to be:
786    /// 1. Idempotent — repeated execution with the same args produces the same result.
787    /// 2. Side-effect-free or cheaply reversible.
788    /// 3. Not subject to user confirmation (`needs_confirmation` must be false at call time).
789    ///
790    /// Default: `false` (safe). Override to `true` only for tools that satisfy all three
791    /// properties. The engine additionally gates on trust level and confirmation status
792    /// regardless of this flag.
793    ///
794    /// # Examples
795    ///
796    /// ```rust
797    /// use zeph_tools::ToolExecutor;
798    ///
799    /// struct ReadOnlyExecutor;
800    /// impl ToolExecutor for ReadOnlyExecutor {
801    ///     async fn execute(&self, _: &str) -> Result<Option<zeph_tools::ToolOutput>, zeph_tools::ToolError> {
802    ///         Ok(None)
803    ///     }
804    ///     fn is_tool_speculatable(&self, _tool_id: &str) -> bool {
805    ///         true // read-only, idempotent
806    ///     }
807    /// }
808    /// ```
809    fn is_tool_speculatable(&self, _tool_id: &str) -> bool {
810        false
811    }
812
813    /// Return `true` when `call` would require user confirmation before execution.
814    ///
815    /// This is a pure metadata/policy query — implementations must **not** execute the tool.
816    /// Used by the speculative engine to gate dispatch without causing double side-effects.
817    ///
818    /// Default: `false`. Executors that enforce a confirmation policy (e.g. `TrustGateExecutor`)
819    /// must override this to reflect their actual policy without executing the tool.
820    fn requires_confirmation(&self, _call: &ToolCall) -> bool {
821        false
822    }
823}
824
825/// Object-safe erased version of [`ToolExecutor`] using boxed futures.
826///
827/// Because [`ToolExecutor`] uses `impl Future` return types, it is not object-safe and
828/// cannot be used as `dyn ToolExecutor`. This trait provides the same interface with
829/// `Pin<Box<dyn Future>>` returns, enabling dynamic dispatch.
830///
831/// Implemented automatically for all `T: ToolExecutor + 'static` via the blanket impl below.
832/// Use [`DynExecutor`] or `Box<dyn ErasedToolExecutor>` when runtime polymorphism is needed.
833pub trait ErasedToolExecutor: Send + Sync {
834    fn execute_erased<'a>(
835        &'a self,
836        response: &'a str,
837    ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>;
838
839    fn execute_confirmed_erased<'a>(
840        &'a self,
841        response: &'a str,
842    ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>;
843
844    fn tool_definitions_erased(&self) -> Vec<crate::registry::ToolDef>;
845
846    fn execute_tool_call_erased<'a>(
847        &'a self,
848        call: &'a ToolCall,
849    ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>;
850
851    fn execute_tool_call_confirmed_erased<'a>(
852        &'a self,
853        call: &'a ToolCall,
854    ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
855    {
856        // TrustGateExecutor overrides ToolExecutor::execute_tool_call_confirmed; the blanket
857        // impl for T: ToolExecutor routes this call through it via execute_tool_call_confirmed_erased.
858        // Other implementors fall back to execute_tool_call_erased (normal enforcement path).
859        self.execute_tool_call_erased(call)
860    }
861
862    /// Inject environment variables for the currently active skill. No-op by default.
863    fn set_skill_env(&self, _env: Option<std::collections::HashMap<String, String>>) {}
864
865    /// Set the effective trust level for the currently active skill. No-op by default.
866    fn set_effective_trust(&self, _level: crate::SkillTrustLevel) {}
867
868    /// Undo the last `n` checkpointed write commands. No-op (unsupported) by default.
869    fn checkpoint_undo_erased(&self, _n: usize) -> CheckpointActionResult {
870        CheckpointActionResult::unsupported()
871    }
872
873    /// Redo the last undone checkpoint. No-op (unsupported) by default.
874    fn checkpoint_redo_erased(&self) -> CheckpointActionResult {
875        CheckpointActionResult::unsupported()
876    }
877
878    /// List the current undo stack entries and redo depth. Returns empty by default.
879    fn checkpoint_list_erased(&self) -> CheckpointListResult {
880        CheckpointListResult::default()
881    }
882
883    /// Whether the executor can safely retry this tool call on a transient error.
884    fn is_tool_retryable_erased(&self, tool_id: &str) -> bool;
885
886    /// Whether a tool call can be safely dispatched speculatively.
887    ///
888    /// Default: `false`. Override to `true` in read-only executors.
889    fn is_tool_speculatable_erased(&self, _tool_id: &str) -> bool {
890        false
891    }
892
893    /// Return `true` when `call` would require user confirmation before execution.
894    ///
895    /// This is a pure metadata/policy query — implementations must **not** execute the tool.
896    /// Used by the speculative engine to gate dispatch without causing double side-effects.
897    ///
898    /// Default: `true` (confirmation required). Implementors that want to allow speculative
899    /// dispatch must explicitly return `false`. The blanket impl for `T: ToolExecutor`
900    /// delegates to [`ToolExecutor::requires_confirmation`].
901    fn requires_confirmation_erased(&self, _call: &ToolCall) -> bool {
902        true
903    }
904}
905
906impl<T: ToolExecutor> ErasedToolExecutor for T {
907    fn execute_erased<'a>(
908        &'a self,
909        response: &'a str,
910    ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
911    {
912        Box::pin(self.execute(response))
913    }
914
915    fn execute_confirmed_erased<'a>(
916        &'a self,
917        response: &'a str,
918    ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
919    {
920        Box::pin(self.execute_confirmed(response))
921    }
922
923    fn tool_definitions_erased(&self) -> Vec<crate::registry::ToolDef> {
924        self.tool_definitions()
925    }
926
927    fn execute_tool_call_erased<'a>(
928        &'a self,
929        call: &'a ToolCall,
930    ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
931    {
932        Box::pin(self.execute_tool_call(call))
933    }
934
935    fn execute_tool_call_confirmed_erased<'a>(
936        &'a self,
937        call: &'a ToolCall,
938    ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
939    {
940        Box::pin(self.execute_tool_call_confirmed(call))
941    }
942
943    fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
944        ToolExecutor::set_skill_env(self, env);
945    }
946
947    fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
948        ToolExecutor::set_effective_trust(self, level);
949    }
950
951    fn checkpoint_undo_erased(&self, n: usize) -> CheckpointActionResult {
952        ToolExecutor::checkpoint_undo(self, n)
953    }
954
955    fn checkpoint_redo_erased(&self) -> CheckpointActionResult {
956        ToolExecutor::checkpoint_redo(self)
957    }
958
959    fn checkpoint_list_erased(&self) -> CheckpointListResult {
960        ToolExecutor::checkpoint_list(self)
961    }
962
963    fn is_tool_retryable_erased(&self, tool_id: &str) -> bool {
964        ToolExecutor::is_tool_retryable(self, tool_id)
965    }
966
967    fn is_tool_speculatable_erased(&self, tool_id: &str) -> bool {
968        ToolExecutor::is_tool_speculatable(self, tool_id)
969    }
970
971    fn requires_confirmation_erased(&self, call: &ToolCall) -> bool {
972        ToolExecutor::requires_confirmation(self, call)
973    }
974}
975
976/// Wraps `Arc<dyn ErasedToolExecutor>` so it can be used as a concrete `ToolExecutor`.
977///
978/// Enables dynamic composition of tool executors at runtime without static type chains.
979pub struct DynExecutor(pub std::sync::Arc<dyn ErasedToolExecutor>);
980
981impl ToolExecutor for DynExecutor {
982    fn execute(
983        &self,
984        response: &str,
985    ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
986        // Clone data to satisfy the 'static-ish bound: erased futures must not borrow self.
987        let inner = std::sync::Arc::clone(&self.0);
988        let response = response.to_owned();
989        async move { inner.execute_erased(&response).await }
990    }
991
992    fn execute_confirmed(
993        &self,
994        response: &str,
995    ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
996        let inner = std::sync::Arc::clone(&self.0);
997        let response = response.to_owned();
998        async move { inner.execute_confirmed_erased(&response).await }
999    }
1000
1001    fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
1002        self.0.tool_definitions_erased()
1003    }
1004
1005    fn execute_tool_call(
1006        &self,
1007        call: &ToolCall,
1008    ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
1009        let inner = std::sync::Arc::clone(&self.0);
1010        let call = call.clone();
1011        async move { inner.execute_tool_call_erased(&call).await }
1012    }
1013
1014    fn execute_tool_call_confirmed(
1015        &self,
1016        call: &ToolCall,
1017    ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
1018        let inner = std::sync::Arc::clone(&self.0);
1019        let call = call.clone();
1020        async move { inner.execute_tool_call_confirmed_erased(&call).await }
1021    }
1022
1023    fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
1024        ErasedToolExecutor::set_skill_env(self.0.as_ref(), env);
1025    }
1026
1027    fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
1028        ErasedToolExecutor::set_effective_trust(self.0.as_ref(), level);
1029    }
1030
1031    fn checkpoint_undo(&self, n: usize) -> CheckpointActionResult {
1032        self.0.checkpoint_undo_erased(n)
1033    }
1034
1035    fn checkpoint_redo(&self) -> CheckpointActionResult {
1036        self.0.checkpoint_redo_erased()
1037    }
1038
1039    fn checkpoint_list(&self) -> CheckpointListResult {
1040        self.0.checkpoint_list_erased()
1041    }
1042
1043    fn is_tool_retryable(&self, tool_id: &str) -> bool {
1044        self.0.is_tool_retryable_erased(tool_id)
1045    }
1046
1047    fn is_tool_speculatable(&self, tool_id: &str) -> bool {
1048        self.0.is_tool_speculatable_erased(tool_id)
1049    }
1050
1051    fn requires_confirmation(&self, call: &ToolCall) -> bool {
1052        self.0.requires_confirmation_erased(call)
1053    }
1054}
1055
1056/// Extract fenced code blocks with the given language marker from text.
1057///
1058/// Searches for `` ```{lang} `` … `` ``` `` pairs, returning trimmed content.
1059#[must_use]
1060pub fn extract_fenced_blocks<'a>(text: &'a str, lang: &str) -> Vec<&'a str> {
1061    let marker = format!("```{lang}");
1062    let marker_len = marker.len();
1063    let mut blocks = Vec::new();
1064    let mut rest = text;
1065
1066    let mut search_from = 0;
1067    while let Some(rel) = rest[search_from..].find(&marker) {
1068        let start = search_from + rel;
1069        let after = &rest[start + marker_len..];
1070        // Word-boundary check: the character immediately after the marker must be
1071        // whitespace, end-of-string, or a non-word character (not alphanumeric / _ / -).
1072        // This prevents "```bash" from matching "```bashrc".
1073        let boundary_ok = after
1074            .chars()
1075            .next()
1076            .is_none_or(|c| !c.is_alphanumeric() && c != '_' && c != '-');
1077        if !boundary_ok {
1078            search_from = start + marker_len;
1079            continue;
1080        }
1081        if let Some(end) = after.find("```") {
1082            blocks.push(after[..end].trim());
1083            rest = &after[end + 3..];
1084            search_from = 0;
1085        } else {
1086            break;
1087        }
1088    }
1089
1090    blocks
1091}
1092
1093#[cfg(test)]
1094mod tests {
1095    use super::*;
1096    use std::assert_matches;
1097
1098    #[test]
1099    fn tool_output_display() {
1100        let output = ToolOutput {
1101            tool_name: ToolName::new("bash"),
1102            summary: "$ echo hello\nhello".to_owned(),
1103            blocks_executed: 1,
1104            filter_stats: None,
1105            diff: None,
1106            streamed: false,
1107            terminal_id: None,
1108            locations: None,
1109            raw_response: None,
1110            claim_source: None,
1111        };
1112        assert_eq!(output.to_string(), "$ echo hello\nhello");
1113    }
1114
1115    #[test]
1116    fn tool_error_blocked_display() {
1117        let err = ToolError::Blocked {
1118            command: "rm -rf /".to_owned(),
1119        };
1120        assert_eq!(err.to_string(), "command blocked by policy: rm -rf /");
1121    }
1122
1123    #[test]
1124    fn tool_error_sandbox_violation_display() {
1125        let err = ToolError::SandboxViolation {
1126            path: "/etc/shadow".to_owned(),
1127        };
1128        assert_eq!(err.to_string(), "path not allowed by sandbox: /etc/shadow");
1129    }
1130
1131    #[test]
1132    fn tool_error_confirmation_required_display() {
1133        let err = ToolError::ConfirmationRequired {
1134            command: "rm -rf /tmp".to_owned(),
1135        };
1136        assert_eq!(
1137            err.to_string(),
1138            "command requires confirmation: rm -rf /tmp"
1139        );
1140    }
1141
1142    #[test]
1143    fn tool_error_timeout_display() {
1144        let err = ToolError::Timeout { timeout_secs: 30 };
1145        assert_eq!(err.to_string(), "command timed out after 30s");
1146    }
1147
1148    #[test]
1149    fn tool_error_invalid_params_display() {
1150        let err = ToolError::InvalidParams {
1151            message: "missing field `command`".to_owned(),
1152        };
1153        assert_eq!(
1154            err.to_string(),
1155            "invalid tool parameters: missing field `command`"
1156        );
1157    }
1158
1159    #[test]
1160    fn deserialize_params_valid() {
1161        #[derive(Debug, serde::Deserialize, PartialEq)]
1162        struct P {
1163            name: String,
1164            count: u32,
1165        }
1166        let mut map = serde_json::Map::new();
1167        map.insert("name".to_owned(), serde_json::json!("test"));
1168        map.insert("count".to_owned(), serde_json::json!(42));
1169        let p: P = deserialize_params(&map).unwrap();
1170        assert_eq!(
1171            p,
1172            P {
1173                name: "test".to_owned(),
1174                count: 42
1175            }
1176        );
1177    }
1178
1179    #[test]
1180    fn deserialize_params_missing_required_field() {
1181        #[derive(Debug, serde::Deserialize)]
1182        #[allow(dead_code)]
1183        struct P {
1184            name: String,
1185        }
1186        let map = serde_json::Map::new();
1187        let err = deserialize_params::<P>(&map).unwrap_err();
1188        assert_matches!(err, ToolError::InvalidParams { .. });
1189    }
1190
1191    #[test]
1192    fn deserialize_params_wrong_type() {
1193        #[derive(Debug, serde::Deserialize)]
1194        #[allow(dead_code)]
1195        struct P {
1196            count: u32,
1197        }
1198        let mut map = serde_json::Map::new();
1199        map.insert("count".to_owned(), serde_json::json!("not a number"));
1200        let err = deserialize_params::<P>(&map).unwrap_err();
1201        assert_matches!(err, ToolError::InvalidParams { .. });
1202    }
1203
1204    #[test]
1205    fn deserialize_params_all_optional_empty() {
1206        #[derive(Debug, serde::Deserialize, PartialEq)]
1207        struct P {
1208            name: Option<String>,
1209        }
1210        let map = serde_json::Map::new();
1211        let p: P = deserialize_params(&map).unwrap();
1212        assert_eq!(p, P { name: None });
1213    }
1214
1215    #[test]
1216    fn deserialize_params_ignores_extra_fields() {
1217        #[derive(Debug, serde::Deserialize, PartialEq)]
1218        struct P {
1219            name: String,
1220        }
1221        let mut map = serde_json::Map::new();
1222        map.insert("name".to_owned(), serde_json::json!("test"));
1223        map.insert("extra".to_owned(), serde_json::json!(true));
1224        let p: P = deserialize_params(&map).unwrap();
1225        assert_eq!(
1226            p,
1227            P {
1228                name: "test".to_owned()
1229            }
1230        );
1231    }
1232
1233    #[test]
1234    fn tool_error_execution_display() {
1235        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "bash not found");
1236        let err = ToolError::Execution(io_err);
1237        assert!(err.to_string().starts_with("execution failed:"));
1238        assert!(err.to_string().contains("bash not found"));
1239    }
1240
1241    // ErrorKind classification tests
1242    #[test]
1243    fn error_kind_timeout_is_transient() {
1244        let err = ToolError::Timeout { timeout_secs: 30 };
1245        assert_eq!(err.kind(), ErrorKind::Transient);
1246    }
1247
1248    #[test]
1249    fn error_kind_blocked_is_permanent() {
1250        let err = ToolError::Blocked {
1251            command: "rm -rf /".to_owned(),
1252        };
1253        assert_eq!(err.kind(), ErrorKind::Permanent);
1254    }
1255
1256    #[test]
1257    fn error_kind_sandbox_violation_is_permanent() {
1258        let err = ToolError::SandboxViolation {
1259            path: "/etc/shadow".to_owned(),
1260        };
1261        assert_eq!(err.kind(), ErrorKind::Permanent);
1262    }
1263
1264    #[test]
1265    fn error_kind_cancelled_is_permanent() {
1266        assert_eq!(ToolError::Cancelled.kind(), ErrorKind::Permanent);
1267    }
1268
1269    #[test]
1270    fn error_kind_invalid_params_is_permanent() {
1271        let err = ToolError::InvalidParams {
1272            message: "bad arg".to_owned(),
1273        };
1274        assert_eq!(err.kind(), ErrorKind::Permanent);
1275    }
1276
1277    #[test]
1278    fn error_kind_confirmation_required_is_permanent() {
1279        let err = ToolError::ConfirmationRequired {
1280            command: "rm /tmp/x".to_owned(),
1281        };
1282        assert_eq!(err.kind(), ErrorKind::Permanent);
1283    }
1284
1285    #[test]
1286    fn error_kind_execution_timed_out_is_transient() {
1287        let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "timeout");
1288        assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
1289    }
1290
1291    #[test]
1292    fn error_kind_execution_interrupted_is_transient() {
1293        let io_err = std::io::Error::new(std::io::ErrorKind::Interrupted, "interrupted");
1294        assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
1295    }
1296
1297    #[test]
1298    fn error_kind_execution_connection_reset_is_transient() {
1299        let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "reset");
1300        assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
1301    }
1302
1303    #[test]
1304    fn error_kind_execution_broken_pipe_is_transient() {
1305        let io_err = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pipe broken");
1306        assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
1307    }
1308
1309    #[test]
1310    fn error_kind_execution_would_block_is_transient() {
1311        let io_err = std::io::Error::new(std::io::ErrorKind::WouldBlock, "would block");
1312        assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
1313    }
1314
1315    #[test]
1316    fn error_kind_execution_connection_aborted_is_transient() {
1317        let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionAborted, "aborted");
1318        assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
1319    }
1320
1321    #[test]
1322    fn error_kind_execution_not_found_is_permanent() {
1323        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "not found");
1324        assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
1325    }
1326
1327    #[test]
1328    fn error_kind_execution_permission_denied_is_permanent() {
1329        let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
1330        assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
1331    }
1332
1333    #[test]
1334    fn error_kind_execution_other_is_permanent() {
1335        let io_err = std::io::Error::other("some other error");
1336        assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
1337    }
1338
1339    #[test]
1340    fn error_kind_execution_already_exists_is_permanent() {
1341        let io_err = std::io::Error::new(std::io::ErrorKind::AlreadyExists, "exists");
1342        assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
1343    }
1344
1345    #[test]
1346    fn error_kind_display() {
1347        assert_eq!(ErrorKind::Transient.to_string(), "transient");
1348        assert_eq!(ErrorKind::Permanent.to_string(), "permanent");
1349    }
1350
1351    #[test]
1352    fn truncate_tool_output_short_passthrough() {
1353        let short = "hello world";
1354        assert_eq!(truncate_tool_output(short), short);
1355    }
1356
1357    #[test]
1358    fn truncate_tool_output_exact_limit() {
1359        let exact = "a".repeat(MAX_TOOL_OUTPUT_CHARS);
1360        assert_eq!(truncate_tool_output(&exact), exact);
1361    }
1362
1363    #[test]
1364    fn truncate_tool_output_long_split() {
1365        let long = "x".repeat(MAX_TOOL_OUTPUT_CHARS + 1000);
1366        let result = truncate_tool_output(&long);
1367        assert!(result.contains("truncated"));
1368        assert!(result.len() < long.len());
1369    }
1370
1371    #[test]
1372    fn truncate_tool_output_notice_contains_count() {
1373        let long = "y".repeat(MAX_TOOL_OUTPUT_CHARS + 2000);
1374        let result = truncate_tool_output(&long);
1375        assert!(result.contains("truncated"));
1376        assert!(result.contains("chars"));
1377    }
1378
1379    #[derive(Debug)]
1380    struct DefaultExecutor;
1381    impl ToolExecutor for DefaultExecutor {
1382        async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
1383            Ok(None)
1384        }
1385    }
1386
1387    #[tokio::test]
1388    async fn execute_tool_call_default_returns_none() {
1389        let exec = DefaultExecutor;
1390        let call = ToolCall {
1391            tool_id: ToolName::new("anything"),
1392            params: serde_json::Map::new(),
1393            caller_id: None,
1394            context: None,
1395
1396            tool_call_id: String::new(),
1397            skill_name: None,
1398        };
1399        let result = exec.execute_tool_call(&call).await.unwrap();
1400        assert!(result.is_none());
1401    }
1402
1403    #[test]
1404    fn filter_stats_savings_pct() {
1405        let fs = FilterStats {
1406            raw_chars: 1000,
1407            filtered_chars: 200,
1408            ..Default::default()
1409        };
1410        assert!((fs.savings_pct() - 80.0).abs() < 0.01);
1411    }
1412
1413    #[test]
1414    fn filter_stats_savings_pct_zero() {
1415        let fs = FilterStats::default();
1416        assert!((fs.savings_pct()).abs() < 0.01);
1417    }
1418
1419    #[test]
1420    fn filter_stats_estimated_tokens_saved() {
1421        let fs = FilterStats {
1422            raw_chars: 1000,
1423            filtered_chars: 200,
1424            ..Default::default()
1425        };
1426        assert_eq!(fs.estimated_tokens_saved(), 200); // (1000 - 200) / 4
1427    }
1428
1429    #[test]
1430    fn filter_stats_format_inline() {
1431        let fs = FilterStats {
1432            raw_chars: 1000,
1433            filtered_chars: 200,
1434            raw_lines: 342,
1435            filtered_lines: 28,
1436            ..Default::default()
1437        };
1438        let line = fs.format_inline("shell");
1439        assert_eq!(line, "[shell] 342 lines \u{2192} 28 lines, 80.0% filtered");
1440    }
1441
1442    #[test]
1443    fn filter_stats_format_inline_zero() {
1444        let fs = FilterStats::default();
1445        let line = fs.format_inline("bash");
1446        assert_eq!(line, "[bash] 0 lines \u{2192} 0 lines, 0.0% filtered");
1447    }
1448
1449    // DynExecutor tests
1450
1451    struct FixedExecutor {
1452        tool_id: &'static str,
1453        output: &'static str,
1454    }
1455
1456    impl ToolExecutor for FixedExecutor {
1457        async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
1458            Ok(Some(ToolOutput {
1459                tool_name: ToolName::new(self.tool_id),
1460                summary: self.output.to_owned(),
1461                blocks_executed: 1,
1462                filter_stats: None,
1463                diff: None,
1464                streamed: false,
1465                terminal_id: None,
1466                locations: None,
1467                raw_response: None,
1468                claim_source: None,
1469            }))
1470        }
1471
1472        fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
1473            vec![]
1474        }
1475
1476        async fn execute_tool_call(
1477            &self,
1478            _call: &ToolCall,
1479        ) -> Result<Option<ToolOutput>, ToolError> {
1480            Ok(Some(ToolOutput {
1481                tool_name: ToolName::new(self.tool_id),
1482                summary: self.output.to_owned(),
1483                blocks_executed: 1,
1484                filter_stats: None,
1485                diff: None,
1486                streamed: false,
1487                terminal_id: None,
1488                locations: None,
1489                raw_response: None,
1490                claim_source: None,
1491            }))
1492        }
1493    }
1494
1495    #[tokio::test]
1496    async fn dyn_executor_execute_delegates() {
1497        let inner = std::sync::Arc::new(FixedExecutor {
1498            tool_id: "bash",
1499            output: "hello",
1500        });
1501        let exec = DynExecutor(inner);
1502        let result = exec.execute("```bash\necho hello\n```").await.unwrap();
1503        assert!(result.is_some());
1504        assert_eq!(result.unwrap().summary, "hello");
1505    }
1506
1507    #[tokio::test]
1508    async fn dyn_executor_execute_confirmed_delegates() {
1509        let inner = std::sync::Arc::new(FixedExecutor {
1510            tool_id: "bash",
1511            output: "confirmed",
1512        });
1513        let exec = DynExecutor(inner);
1514        let result = exec.execute_confirmed("...").await.unwrap();
1515        assert!(result.is_some());
1516        assert_eq!(result.unwrap().summary, "confirmed");
1517    }
1518
1519    #[test]
1520    fn dyn_executor_tool_definitions_delegates() {
1521        let inner = std::sync::Arc::new(FixedExecutor {
1522            tool_id: "my_tool",
1523            output: "",
1524        });
1525        let exec = DynExecutor(inner);
1526        // FixedExecutor returns empty definitions; verify delegation occurs without panic.
1527        let defs = exec.tool_definitions();
1528        assert!(defs.is_empty());
1529    }
1530
1531    #[tokio::test]
1532    async fn dyn_executor_execute_tool_call_delegates() {
1533        let inner = std::sync::Arc::new(FixedExecutor {
1534            tool_id: "bash",
1535            output: "tool_call_result",
1536        });
1537        let exec = DynExecutor(inner);
1538        let call = ToolCall {
1539            tool_id: ToolName::new("bash"),
1540            params: serde_json::Map::new(),
1541            caller_id: None,
1542            context: None,
1543
1544            tool_call_id: String::new(),
1545            skill_name: None,
1546        };
1547        let result = exec.execute_tool_call(&call).await.unwrap();
1548        assert!(result.is_some());
1549        assert_eq!(result.unwrap().summary, "tool_call_result");
1550    }
1551
1552    #[test]
1553    fn dyn_executor_set_effective_trust_delegates() {
1554        use std::sync::atomic::{AtomicU8, Ordering};
1555
1556        struct TrustCapture(AtomicU8);
1557        impl ToolExecutor for TrustCapture {
1558            async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
1559                Ok(None)
1560            }
1561            fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
1562                // encode: Trusted=0, Verified=1, Quarantined=2, Blocked=3
1563                let v = match level {
1564                    crate::SkillTrustLevel::Trusted => 0u8,
1565                    crate::SkillTrustLevel::Verified => 1,
1566                    crate::SkillTrustLevel::Quarantined => 2,
1567                    _ => 3,
1568                };
1569                self.0.store(v, Ordering::Relaxed);
1570            }
1571        }
1572
1573        let inner = std::sync::Arc::new(TrustCapture(AtomicU8::new(0)));
1574        let exec =
1575            DynExecutor(std::sync::Arc::clone(&inner) as std::sync::Arc<dyn ErasedToolExecutor>);
1576        ToolExecutor::set_effective_trust(&exec, crate::SkillTrustLevel::Quarantined);
1577        assert_eq!(inner.0.load(Ordering::Relaxed), 2);
1578
1579        ToolExecutor::set_effective_trust(&exec, crate::SkillTrustLevel::Blocked);
1580        assert_eq!(inner.0.load(Ordering::Relaxed), 3);
1581    }
1582
1583    #[test]
1584    fn extract_fenced_blocks_no_prefix_match() {
1585        // ```bashrc must NOT match when searching for "bash"
1586        assert!(extract_fenced_blocks("```bashrc\nfoo\n```", "bash").is_empty());
1587        // exact match
1588        assert_eq!(
1589            extract_fenced_blocks("```bash\nfoo\n```", "bash"),
1590            vec!["foo"]
1591        );
1592        // trailing space is fine
1593        assert_eq!(
1594            extract_fenced_blocks("```bash \nfoo\n```", "bash"),
1595            vec!["foo"]
1596        );
1597    }
1598
1599    // ── ToolError::category() delegation tests ────────────────────────────────
1600
1601    #[test]
1602    fn tool_error_http_400_category_is_invalid_parameters() {
1603        use crate::error_taxonomy::ToolErrorCategory;
1604        let err = ToolError::Http {
1605            status: 400,
1606            message: "bad request".to_owned(),
1607        };
1608        assert_eq!(err.category(), ToolErrorCategory::InvalidParameters);
1609    }
1610
1611    #[test]
1612    fn tool_error_http_401_category_is_policy_blocked() {
1613        use crate::error_taxonomy::ToolErrorCategory;
1614        let err = ToolError::Http {
1615            status: 401,
1616            message: "unauthorized".to_owned(),
1617        };
1618        assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1619    }
1620
1621    #[test]
1622    fn tool_error_http_403_category_is_policy_blocked() {
1623        use crate::error_taxonomy::ToolErrorCategory;
1624        let err = ToolError::Http {
1625            status: 403,
1626            message: "forbidden".to_owned(),
1627        };
1628        assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1629    }
1630
1631    #[test]
1632    fn tool_error_http_404_category_is_permanent_failure() {
1633        use crate::error_taxonomy::ToolErrorCategory;
1634        let err = ToolError::Http {
1635            status: 404,
1636            message: "not found".to_owned(),
1637        };
1638        assert_eq!(err.category(), ToolErrorCategory::PermanentFailure);
1639    }
1640
1641    #[test]
1642    fn tool_error_http_429_category_is_rate_limited() {
1643        use crate::error_taxonomy::ToolErrorCategory;
1644        let err = ToolError::Http {
1645            status: 429,
1646            message: "too many requests".to_owned(),
1647        };
1648        assert_eq!(err.category(), ToolErrorCategory::RateLimited);
1649    }
1650
1651    #[test]
1652    fn tool_error_http_500_category_is_server_error() {
1653        use crate::error_taxonomy::ToolErrorCategory;
1654        let err = ToolError::Http {
1655            status: 500,
1656            message: "internal server error".to_owned(),
1657        };
1658        assert_eq!(err.category(), ToolErrorCategory::ServerError);
1659    }
1660
1661    #[test]
1662    fn tool_error_http_502_category_is_server_error() {
1663        use crate::error_taxonomy::ToolErrorCategory;
1664        let err = ToolError::Http {
1665            status: 502,
1666            message: "bad gateway".to_owned(),
1667        };
1668        assert_eq!(err.category(), ToolErrorCategory::ServerError);
1669    }
1670
1671    #[test]
1672    fn tool_error_http_503_category_is_server_error() {
1673        use crate::error_taxonomy::ToolErrorCategory;
1674        let err = ToolError::Http {
1675            status: 503,
1676            message: "service unavailable".to_owned(),
1677        };
1678        assert_eq!(err.category(), ToolErrorCategory::ServerError);
1679    }
1680
1681    #[test]
1682    fn tool_error_http_503_is_transient_triggers_phase2_retry() {
1683        // Phase 2 retry fires when err.kind() == ErrorKind::Transient.
1684        // Verify the full chain: Http{503} -> ServerError -> is_retryable() -> Transient.
1685        let err = ToolError::Http {
1686            status: 503,
1687            message: "service unavailable".to_owned(),
1688        };
1689        assert_eq!(
1690            err.kind(),
1691            ErrorKind::Transient,
1692            "HTTP 503 must be Transient so Phase 2 retry fires"
1693        );
1694    }
1695
1696    #[test]
1697    fn tool_error_blocked_category_is_policy_blocked() {
1698        use crate::error_taxonomy::ToolErrorCategory;
1699        let err = ToolError::Blocked {
1700            command: "rm -rf /".to_owned(),
1701        };
1702        assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1703    }
1704
1705    #[test]
1706    fn tool_error_sandbox_violation_category_is_policy_blocked() {
1707        use crate::error_taxonomy::ToolErrorCategory;
1708        let err = ToolError::SandboxViolation {
1709            path: "/etc/shadow".to_owned(),
1710        };
1711        assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1712    }
1713
1714    #[test]
1715    fn tool_error_confirmation_required_category() {
1716        use crate::error_taxonomy::ToolErrorCategory;
1717        let err = ToolError::ConfirmationRequired {
1718            command: "rm /tmp/x".to_owned(),
1719        };
1720        assert_eq!(err.category(), ToolErrorCategory::ConfirmationRequired);
1721    }
1722
1723    #[test]
1724    fn tool_error_timeout_category() {
1725        use crate::error_taxonomy::ToolErrorCategory;
1726        let err = ToolError::Timeout { timeout_secs: 30 };
1727        assert_eq!(err.category(), ToolErrorCategory::Timeout);
1728    }
1729
1730    #[test]
1731    fn tool_error_cancelled_category() {
1732        use crate::error_taxonomy::ToolErrorCategory;
1733        assert_eq!(
1734            ToolError::Cancelled.category(),
1735            ToolErrorCategory::Cancelled
1736        );
1737    }
1738
1739    #[test]
1740    fn tool_error_invalid_params_category() {
1741        use crate::error_taxonomy::ToolErrorCategory;
1742        let err = ToolError::InvalidParams {
1743            message: "missing field".to_owned(),
1744        };
1745        assert_eq!(err.category(), ToolErrorCategory::InvalidParameters);
1746    }
1747
1748    // B2 regression: Execution(NotFound) must NOT produce ToolNotFound.
1749    #[test]
1750    fn tool_error_execution_not_found_category_is_permanent_failure() {
1751        use crate::error_taxonomy::ToolErrorCategory;
1752        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "bash: not found");
1753        let err = ToolError::Execution(io_err);
1754        let cat = err.category();
1755        assert_ne!(
1756            cat,
1757            ToolErrorCategory::ToolNotFound,
1758            "Execution(NotFound) must NOT map to ToolNotFound"
1759        );
1760        assert_eq!(cat, ToolErrorCategory::PermanentFailure);
1761    }
1762
1763    #[test]
1764    fn tool_error_execution_timed_out_category_is_timeout() {
1765        use crate::error_taxonomy::ToolErrorCategory;
1766        let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "timed out");
1767        assert_eq!(
1768            ToolError::Execution(io_err).category(),
1769            ToolErrorCategory::Timeout
1770        );
1771    }
1772
1773    #[test]
1774    fn tool_error_execution_connection_refused_category_is_network_error() {
1775        use crate::error_taxonomy::ToolErrorCategory;
1776        let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
1777        assert_eq!(
1778            ToolError::Execution(io_err).category(),
1779            ToolErrorCategory::NetworkError
1780        );
1781    }
1782
1783    // B4 regression: Http/network/transient categories must NOT be quality failures.
1784    #[test]
1785    fn b4_tool_error_http_429_not_quality_failure() {
1786        let err = ToolError::Http {
1787            status: 429,
1788            message: "rate limited".to_owned(),
1789        };
1790        assert!(
1791            !err.category().is_quality_failure(),
1792            "RateLimited must not be a quality failure"
1793        );
1794    }
1795
1796    #[test]
1797    fn b4_tool_error_http_503_not_quality_failure() {
1798        let err = ToolError::Http {
1799            status: 503,
1800            message: "service unavailable".to_owned(),
1801        };
1802        assert!(
1803            !err.category().is_quality_failure(),
1804            "ServerError must not be a quality failure"
1805        );
1806    }
1807
1808    #[test]
1809    fn b4_tool_error_execution_timed_out_not_quality_failure() {
1810        let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "timeout");
1811        assert!(
1812            !ToolError::Execution(io_err).category().is_quality_failure(),
1813            "Timeout must not be a quality failure"
1814        );
1815    }
1816
1817    // ── ToolError::Shell category tests ──────────────────────────────────────
1818
1819    #[test]
1820    fn tool_error_shell_exit126_is_policy_blocked() {
1821        use crate::error_taxonomy::ToolErrorCategory;
1822        let err = ToolError::Shell {
1823            exit_code: 126,
1824            category: ToolErrorCategory::PolicyBlocked,
1825            message: "permission denied".to_owned(),
1826        };
1827        assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1828    }
1829
1830    #[test]
1831    fn tool_error_shell_exit127_is_permanent_failure() {
1832        use crate::error_taxonomy::ToolErrorCategory;
1833        let err = ToolError::Shell {
1834            exit_code: 127,
1835            category: ToolErrorCategory::PermanentFailure,
1836            message: "command not found".to_owned(),
1837        };
1838        assert_eq!(err.category(), ToolErrorCategory::PermanentFailure);
1839        assert!(!err.category().is_retryable());
1840    }
1841
1842    #[test]
1843    fn tool_error_shell_not_quality_failure() {
1844        use crate::error_taxonomy::ToolErrorCategory;
1845        let err = ToolError::Shell {
1846            exit_code: 127,
1847            category: ToolErrorCategory::PermanentFailure,
1848            message: "command not found".to_owned(),
1849        };
1850        // Shell exit errors are not attributable to LLM output quality.
1851        assert!(!err.category().is_quality_failure());
1852    }
1853
1854    // ── requires_confirmation / requires_confirmation_erased tests (#3644) ───
1855
1856    /// Stub implementing only `ToolExecutor` without overriding `requires_confirmation`.
1857    struct StubExecutor;
1858    impl ToolExecutor for StubExecutor {
1859        async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
1860            Ok(None)
1861        }
1862    }
1863
1864    /// Stub that always signals confirmation is required via `ToolExecutor::requires_confirmation`.
1865    struct ConfirmingExecutor;
1866    impl ToolExecutor for ConfirmingExecutor {
1867        async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
1868            Ok(None)
1869        }
1870        fn requires_confirmation(&self, _call: &ToolCall) -> bool {
1871            true
1872        }
1873    }
1874
1875    fn dummy_call() -> ToolCall {
1876        ToolCall {
1877            tool_id: ToolName::new("test"),
1878            params: serde_json::Map::new(),
1879            caller_id: None,
1880            context: None,
1881
1882            tool_call_id: String::new(),
1883            skill_name: None,
1884        }
1885    }
1886
1887    #[test]
1888    fn requires_confirmation_default_is_false_on_tool_executor() {
1889        let exec = StubExecutor;
1890        assert!(
1891            !exec.requires_confirmation(&dummy_call()),
1892            "ToolExecutor default requires_confirmation must be false"
1893        );
1894    }
1895
1896    #[test]
1897    fn requires_confirmation_erased_delegates_to_tool_executor_default() {
1898        // blanket impl routes erased → ToolExecutor::requires_confirmation (= false)
1899        let exec = StubExecutor;
1900        assert!(
1901            !ErasedToolExecutor::requires_confirmation_erased(&exec, &dummy_call()),
1902            "requires_confirmation_erased via blanket impl must return false for stub executor"
1903        );
1904    }
1905
1906    #[test]
1907    fn requires_confirmation_erased_delegates_override() {
1908        // ConfirmingExecutor overrides requires_confirmation → true;
1909        // blanket impl must propagate this.
1910        let exec = ConfirmingExecutor;
1911        assert!(
1912            ErasedToolExecutor::requires_confirmation_erased(&exec, &dummy_call()),
1913            "requires_confirmation_erased must return true when ToolExecutor override returns true"
1914        );
1915    }
1916
1917    #[test]
1918    fn requires_confirmation_erased_default_on_erased_trait_is_true() {
1919        // ErasedToolExecutor's own default (trait method body) returns true.
1920        // We construct a DynExecutor wrapping ConfirmingExecutor and verify via the erased path.
1921        // (We cannot instantiate ErasedToolExecutor directly without a concrete type.)
1922        // Instead verify via a type that only implements ErasedToolExecutor manually:
1923        struct ManualErased;
1924        impl ErasedToolExecutor for ManualErased {
1925            fn execute_erased<'a>(
1926                &'a self,
1927                _response: &'a str,
1928            ) -> std::pin::Pin<
1929                Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>,
1930            > {
1931                Box::pin(std::future::ready(Ok(None)))
1932            }
1933            fn execute_confirmed_erased<'a>(
1934                &'a self,
1935                _response: &'a str,
1936            ) -> std::pin::Pin<
1937                Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>,
1938            > {
1939                Box::pin(std::future::ready(Ok(None)))
1940            }
1941            fn tool_definitions_erased(&self) -> Vec<crate::registry::ToolDef> {
1942                vec![]
1943            }
1944            fn execute_tool_call_erased<'a>(
1945                &'a self,
1946                _call: &'a ToolCall,
1947            ) -> std::pin::Pin<
1948                Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>,
1949            > {
1950                Box::pin(std::future::ready(Ok(None)))
1951            }
1952            fn is_tool_retryable_erased(&self, _tool_id: &str) -> bool {
1953                false
1954            }
1955            // requires_confirmation_erased NOT overridden → trait default returns true
1956        }
1957        let exec = ManualErased;
1958        assert!(
1959            exec.requires_confirmation_erased(&dummy_call()),
1960            "ErasedToolExecutor trait-level default for requires_confirmation_erased must be true"
1961        );
1962    }
1963
1964    // ── DynExecutor::requires_confirmation delegation tests (#3650) ──────────
1965
1966    #[test]
1967    fn dyn_executor_requires_confirmation_delegates() {
1968        let inner = std::sync::Arc::new(ConfirmingExecutor);
1969        let exec =
1970            DynExecutor(std::sync::Arc::clone(&inner) as std::sync::Arc<dyn ErasedToolExecutor>);
1971        assert!(
1972            ToolExecutor::requires_confirmation(&exec, &dummy_call()),
1973            "DynExecutor must delegate requires_confirmation to inner executor"
1974        );
1975    }
1976
1977    #[test]
1978    fn dyn_executor_requires_confirmation_default_false() {
1979        let inner = std::sync::Arc::new(StubExecutor);
1980        let exec =
1981            DynExecutor(std::sync::Arc::clone(&inner) as std::sync::Arc<dyn ErasedToolExecutor>);
1982        assert!(
1983            !ToolExecutor::requires_confirmation(&exec, &dummy_call()),
1984            "DynExecutor must return false when inner executor does not require confirmation"
1985        );
1986    }
1987}