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