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