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
8/// Data for rendering file diffs in the TUI.
9///
10/// Produced by [`ShellExecutor`](crate::ShellExecutor) and [`FileExecutor`](crate::FileExecutor)
11/// when a tool call modifies a tracked file. The TUI uses this to display a side-by-side diff.
12#[derive(Debug, Clone)]
13pub struct DiffData {
14    /// Relative or absolute path to the file that was modified.
15    pub file_path: String,
16    /// File content before the tool executed.
17    pub old_content: String,
18    /// File content after the tool executed.
19    pub new_content: String,
20}
21
22/// Structured tool invocation from LLM.
23///
24/// Produced by the agent loop when the LLM emits a structured tool call (as opposed to
25/// a legacy fenced code block). Dispatched to [`ToolExecutor::execute_tool_call`].
26///
27/// # Example
28///
29/// ```rust
30/// use zeph_tools::ToolCall;
31/// use zeph_common::ToolName;
32///
33/// let call = ToolCall {
34///     tool_id: ToolName::new("bash"),
35///     params: {
36///         let mut m = serde_json::Map::new();
37///         m.insert("command".to_owned(), serde_json::Value::String("echo hello".to_owned()));
38///         m
39///     },
40///     caller_id: Some("user-42".to_owned()),
41/// };
42/// assert_eq!(call.tool_id, "bash");
43/// ```
44#[derive(Debug, Clone)]
45pub struct ToolCall {
46    /// The tool identifier, matching a value from [`ToolExecutor::tool_definitions`].
47    pub tool_id: ToolName,
48    /// JSON parameters for the tool call, deserialized into the tool's parameter struct.
49    pub params: serde_json::Map<String, serde_json::Value>,
50    /// Opaque caller identifier propagated from the channel (user ID, session ID, etc.).
51    /// `None` for system-initiated calls (scheduler, self-learning, internal).
52    pub caller_id: Option<String>,
53}
54
55/// Cumulative filter statistics for a single tool execution.
56///
57/// Populated by [`ShellExecutor`](crate::ShellExecutor) when output filters are configured.
58/// Displayed in the TUI to show how much output was compacted before being sent to the LLM.
59#[derive(Debug, Clone, Default)]
60pub struct FilterStats {
61    /// Raw character count before filtering.
62    pub raw_chars: usize,
63    /// Character count after filtering.
64    pub filtered_chars: usize,
65    /// Raw line count before filtering.
66    pub raw_lines: usize,
67    /// Line count after filtering.
68    pub filtered_lines: usize,
69    /// Worst-case confidence across all applied filters.
70    pub confidence: Option<crate::FilterConfidence>,
71    /// The shell command that produced this output, for display purposes.
72    pub command: Option<String>,
73    /// Zero-based line indices that were kept after filtering.
74    pub kept_lines: Vec<usize>,
75}
76
77impl FilterStats {
78    /// Returns the percentage of characters removed by filtering.
79    ///
80    /// Returns `0.0` when there was no raw output to filter.
81    #[must_use]
82    #[allow(clippy::cast_precision_loss)]
83    pub fn savings_pct(&self) -> f64 {
84        if self.raw_chars == 0 {
85            return 0.0;
86        }
87        (1.0 - self.filtered_chars as f64 / self.raw_chars as f64) * 100.0
88    }
89
90    /// Estimates the number of LLM tokens saved by filtering.
91    ///
92    /// Uses the 4-chars-per-token approximation. Suitable for logging and metrics,
93    /// not for billing or exact budget calculations.
94    #[must_use]
95    pub fn estimated_tokens_saved(&self) -> usize {
96        self.raw_chars.saturating_sub(self.filtered_chars) / 4
97    }
98
99    /// Formats a one-line filter summary for log messages and TUI status.
100    ///
101    /// # Example
102    ///
103    /// ```rust
104    /// use zeph_tools::FilterStats;
105    ///
106    /// let stats = FilterStats {
107    ///     raw_chars: 1000,
108    ///     filtered_chars: 400,
109    ///     raw_lines: 50,
110    ///     filtered_lines: 20,
111    ///     command: Some("cargo build".to_owned()),
112    ///     ..Default::default()
113    /// };
114    /// let summary = stats.format_inline("shell");
115    /// assert!(summary.contains("60.0% filtered"));
116    /// ```
117    #[must_use]
118    pub fn format_inline(&self, tool_name: &str) -> String {
119        let cmd_label = self
120            .command
121            .as_deref()
122            .map(|c| {
123                let trimmed = c.trim();
124                if trimmed.len() > 60 {
125                    format!(" `{}…`", &trimmed[..57])
126                } else {
127                    format!(" `{trimmed}`")
128                }
129            })
130            .unwrap_or_default();
131        format!(
132            "[{tool_name}]{cmd_label} {} lines \u{2192} {} lines, {:.1}% filtered",
133            self.raw_lines,
134            self.filtered_lines,
135            self.savings_pct()
136        )
137    }
138}
139
140/// Provenance of a tool execution result.
141///
142/// Set by each executor at `ToolOutput` construction time. Used by the sanitizer bridge
143/// in `zeph-core` to select the appropriate `ContentSourceKind` and trust level.
144/// `None` means the source is unspecified (pass-through code, mocks, tests).
145#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
146#[serde(rename_all = "snake_case")]
147pub enum ClaimSource {
148    /// Local shell command execution.
149    Shell,
150    /// Local file system read/write.
151    FileSystem,
152    /// HTTP web scrape.
153    WebScrape,
154    /// MCP server tool response.
155    Mcp,
156    /// A2A agent message.
157    A2a,
158    /// Code search (LSP or semantic).
159    CodeSearch,
160    /// Agent diagnostics (internal).
161    Diagnostics,
162    /// Memory retrieval (semantic search).
163    Memory,
164}
165
166/// Structured result from tool execution.
167///
168/// Returned by every [`ToolExecutor`] implementation on success. The agent loop uses
169/// [`ToolOutput::summary`] as the tool result text injected into the LLM context.
170///
171/// # Example
172///
173/// ```rust
174/// use zeph_tools::{ToolOutput, executor::ClaimSource};
175/// use zeph_common::ToolName;
176///
177/// let output = ToolOutput {
178///     tool_name: ToolName::new("shell"),
179///     summary: "hello\n".to_owned(),
180///     blocks_executed: 1,
181///     filter_stats: None,
182///     diff: None,
183///     streamed: false,
184///     terminal_id: None,
185///     locations: None,
186///     raw_response: None,
187///     claim_source: Some(ClaimSource::Shell),
188/// };
189/// assert_eq!(output.to_string(), "hello\n");
190/// ```
191#[derive(Debug, Clone)]
192pub struct ToolOutput {
193    /// Name of the tool that produced this output (e.g. `"shell"`, `"web-scrape"`).
194    pub tool_name: ToolName,
195    /// Human-readable result text injected into the LLM context.
196    pub summary: String,
197    /// Number of code blocks processed in this invocation.
198    pub blocks_executed: u32,
199    /// Output filter statistics when filtering was applied, `None` otherwise.
200    pub filter_stats: Option<FilterStats>,
201    /// File diff data for TUI display when the tool modified a tracked file.
202    pub diff: Option<DiffData>,
203    /// Whether this tool already streamed its output via `ToolEvent` channel.
204    pub streamed: bool,
205    /// Terminal ID when the tool was executed via IDE terminal (ACP terminal/* protocol).
206    pub terminal_id: Option<String>,
207    /// File paths touched by this tool call, for IDE follow-along (e.g. `ToolCallLocation`).
208    pub locations: Option<Vec<String>>,
209    /// Structured tool response payload for ACP intermediate `tool_call_update` notifications.
210    pub raw_response: Option<serde_json::Value>,
211    /// Provenance of this tool result. Set by the executor at construction time.
212    /// `None` in pass-through wrappers, mocks, and tests.
213    pub claim_source: Option<ClaimSource>,
214}
215
216impl fmt::Display for ToolOutput {
217    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
218        f.write_str(&self.summary)
219    }
220}
221
222/// Maximum characters of tool output injected into the LLM context without truncation.
223///
224/// Output that exceeds this limit is split into a head and tail via [`truncate_tool_output`]
225/// to keep both the beginning and end of large command outputs.
226pub const MAX_TOOL_OUTPUT_CHARS: usize = 30_000;
227
228/// Truncate tool output that exceeds [`MAX_TOOL_OUTPUT_CHARS`] using a head+tail split.
229///
230/// Equivalent to `truncate_tool_output_at(output, MAX_TOOL_OUTPUT_CHARS)`.
231///
232/// # Example
233///
234/// ```rust
235/// use zeph_tools::executor::truncate_tool_output;
236///
237/// let short = "hello world";
238/// assert_eq!(truncate_tool_output(short), short);
239/// ```
240#[must_use]
241pub fn truncate_tool_output(output: &str) -> String {
242    truncate_tool_output_at(output, MAX_TOOL_OUTPUT_CHARS)
243}
244
245/// Truncate tool output that exceeds `max_chars` using a head+tail split.
246///
247/// Preserves the first and last `max_chars / 2` characters and inserts a truncation
248/// marker in the middle. Both boundaries are snapped to valid UTF-8 character boundaries.
249///
250/// # Example
251///
252/// ```rust
253/// use zeph_tools::executor::truncate_tool_output_at;
254///
255/// let long = "a".repeat(200);
256/// let truncated = truncate_tool_output_at(&long, 100);
257/// assert!(truncated.contains("truncated"));
258/// assert!(truncated.len() < long.len());
259/// ```
260#[must_use]
261pub fn truncate_tool_output_at(output: &str, max_chars: usize) -> String {
262    if output.len() <= max_chars {
263        return output.to_string();
264    }
265
266    let half = max_chars / 2;
267    let head_end = output.floor_char_boundary(half);
268    let tail_start = output.ceil_char_boundary(output.len() - half);
269    let head = &output[..head_end];
270    let tail = &output[tail_start..];
271    let truncated = output.len() - head_end - (output.len() - tail_start);
272
273    format!(
274        "{head}\n\n... [truncated {truncated} chars, showing first and last ~{half} chars] ...\n\n{tail}"
275    )
276}
277
278/// Event emitted during tool execution for real-time UI updates.
279///
280/// Sent over the [`ToolEventTx`] channel to the TUI or channel adapter.
281/// Each event variant corresponds to a phase in the tool execution lifecycle.
282#[derive(Debug, Clone)]
283pub enum ToolEvent {
284    /// The tool has started. Displayed in the TUI as a spinner with the command text.
285    Started {
286        tool_name: ToolName,
287        command: String,
288    },
289    /// A chunk of streaming output was produced (e.g. from a long-running command).
290    OutputChunk {
291        tool_name: ToolName,
292        command: String,
293        chunk: String,
294    },
295    /// The tool finished. Contains the full output and optional filter/diff data.
296    Completed {
297        tool_name: ToolName,
298        command: String,
299        /// Full output text (possibly filtered and truncated).
300        output: String,
301        /// `true` when the tool exited successfully, `false` on error.
302        success: bool,
303        filter_stats: Option<FilterStats>,
304        diff: Option<DiffData>,
305    },
306    /// A transactional rollback was performed, restoring or deleting files.
307    Rollback {
308        tool_name: ToolName,
309        command: String,
310        /// Number of files restored to their pre-execution content.
311        restored_count: usize,
312        /// Number of files that did not exist before execution and were deleted.
313        deleted_count: usize,
314    },
315}
316
317/// Sender half of the unbounded channel used to stream [`ToolEvent`]s to the UI.
318///
319/// Obtained from [`tokio::sync::mpsc::unbounded_channel`] and injected into executors
320/// via builder methods (e.g. [`ShellExecutor::with_tool_event_tx`](crate::ShellExecutor)).
321pub type ToolEventTx = tokio::sync::mpsc::UnboundedSender<ToolEvent>;
322
323/// Classifies a tool error as transient (retryable) or permanent (abort immediately).
324///
325/// Transient errors may succeed on retry (network blips, race conditions).
326/// Permanent errors will not succeed regardless of retries (policy, bad args, not found).
327#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
328pub enum ErrorKind {
329    Transient,
330    Permanent,
331}
332
333impl std::fmt::Display for ErrorKind {
334    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
335        match self {
336            Self::Transient => f.write_str("transient"),
337            Self::Permanent => f.write_str("permanent"),
338        }
339    }
340}
341
342/// Errors that can occur during tool execution.
343#[derive(Debug, thiserror::Error)]
344pub enum ToolError {
345    #[error("command blocked by policy: {command}")]
346    Blocked { command: String },
347
348    #[error("path not allowed by sandbox: {path}")]
349    SandboxViolation { path: String },
350
351    #[error("command requires confirmation: {command}")]
352    ConfirmationRequired { command: String },
353
354    #[error("command timed out after {timeout_secs}s")]
355    Timeout { timeout_secs: u64 },
356
357    #[error("operation cancelled")]
358    Cancelled,
359
360    #[error("invalid tool parameters: {message}")]
361    InvalidParams { message: String },
362
363    #[error("execution failed: {0}")]
364    Execution(#[from] std::io::Error),
365
366    /// HTTP or API error with status code for fine-grained classification.
367    ///
368    /// Used by `WebScrapeExecutor` and other HTTP-based tools to preserve the status
369    /// code for taxonomy classification. Scope: HTTP tools only (MCP uses a separate path).
370    #[error("HTTP error {status}: {message}")]
371    Http { status: u16, message: String },
372
373    /// Shell execution error with explicit exit code and pre-classified category.
374    ///
375    /// Used by `ShellExecutor` when the exit code or stderr content maps to a known
376    /// taxonomy category (e.g., exit 126 → `PolicyBlocked`, exit 127 → `PermanentFailure`).
377    /// Preserves the exit code for audit logging and the category for skill evolution.
378    #[error("shell error (exit {exit_code}): {message}")]
379    Shell {
380        exit_code: i32,
381        category: crate::error_taxonomy::ToolErrorCategory,
382        message: String,
383    },
384
385    #[error("snapshot failed: {reason}")]
386    SnapshotFailed { reason: String },
387}
388
389impl ToolError {
390    /// Fine-grained error classification using the 12-category taxonomy.
391    ///
392    /// Prefer `category()` over `kind()` for new code. `kind()` is preserved for
393    /// backward compatibility and delegates to `category().error_kind()`.
394    #[must_use]
395    pub fn category(&self) -> crate::error_taxonomy::ToolErrorCategory {
396        use crate::error_taxonomy::{ToolErrorCategory, classify_http_status, classify_io_error};
397        match self {
398            Self::Blocked { .. } | Self::SandboxViolation { .. } => {
399                ToolErrorCategory::PolicyBlocked
400            }
401            Self::ConfirmationRequired { .. } => ToolErrorCategory::ConfirmationRequired,
402            Self::Timeout { .. } => ToolErrorCategory::Timeout,
403            Self::Cancelled => ToolErrorCategory::Cancelled,
404            Self::InvalidParams { .. } => ToolErrorCategory::InvalidParameters,
405            Self::Http { status, .. } => classify_http_status(*status),
406            Self::Execution(io_err) => classify_io_error(io_err),
407            Self::Shell { category, .. } => *category,
408            Self::SnapshotFailed { .. } => ToolErrorCategory::PermanentFailure,
409        }
410    }
411
412    /// Coarse classification for backward compatibility. Delegates to `category().error_kind()`.
413    ///
414    /// For `Execution(io::Error)`, the classification inspects `io::Error::kind()`:
415    /// - Transient: `TimedOut`, `WouldBlock`, `Interrupted`, `ConnectionReset`,
416    ///   `ConnectionAborted`, `BrokenPipe` — these may succeed on retry.
417    /// - Permanent: `NotFound`, `PermissionDenied`, `AlreadyExists`, and all other
418    ///   I/O error kinds — retrying would waste time with no benefit.
419    #[must_use]
420    pub fn kind(&self) -> ErrorKind {
421        use crate::error_taxonomy::ToolErrorCategoryExt;
422        self.category().error_kind()
423    }
424}
425
426/// Deserialize tool call params from a `serde_json::Map<String, Value>` into a typed struct.
427///
428/// # Errors
429///
430/// Returns `ToolError::InvalidParams` when deserialization fails.
431pub fn deserialize_params<T: serde::de::DeserializeOwned>(
432    params: &serde_json::Map<String, serde_json::Value>,
433) -> Result<T, ToolError> {
434    let obj = serde_json::Value::Object(params.clone());
435    serde_json::from_value(obj).map_err(|e| ToolError::InvalidParams {
436        message: e.to_string(),
437    })
438}
439
440/// Async trait for tool execution backends.
441///
442/// Implementations include [`ShellExecutor`](crate::ShellExecutor),
443/// [`WebScrapeExecutor`](crate::WebScrapeExecutor), [`CompositeExecutor`](crate::CompositeExecutor),
444/// and [`FileExecutor`](crate::FileExecutor).
445///
446/// # Contract
447///
448/// - [`execute`](ToolExecutor::execute) and [`execute_tool_call`](ToolExecutor::execute_tool_call)
449///   return `Ok(None)` when the executor does not handle the given input — callers must not
450///   treat `None` as an error.
451/// - All methods must be `Send + Sync` and free of blocking I/O.
452/// - Implementations must enforce their own security controls (blocklists, sandboxes, SSRF
453///   protection) before executing any side-effectful operation.
454/// - [`execute_confirmed`](ToolExecutor::execute_confirmed) and
455///   [`execute_tool_call_confirmed`](ToolExecutor::execute_tool_call_confirmed) bypass
456///   confirmation gates only — all other security controls remain active.
457///
458/// # Two Invocation Paths
459///
460/// **Legacy fenced blocks**: The agent loop passes the raw LLM response string to [`execute`](ToolExecutor::execute).
461/// The executor parses ` ```bash ` or ` ```scrape ` blocks and executes each one.
462///
463/// **Structured tool calls**: The agent loop constructs a [`ToolCall`] from the LLM's
464/// JSON tool-use response and dispatches it via [`execute_tool_call`](ToolExecutor::execute_tool_call).
465/// This is the preferred path for new code.
466///
467/// # Example
468///
469/// ```rust
470/// use zeph_tools::{ToolExecutor, ToolCall, ToolOutput, ToolError, executor::ClaimSource};
471///
472/// #[derive(Debug)]
473/// struct EchoExecutor;
474///
475/// impl ToolExecutor for EchoExecutor {
476///     async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
477///         Ok(None) // not a fenced-block executor
478///     }
479///
480///     async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
481///         if call.tool_id != "echo" {
482///             return Ok(None);
483///         }
484///         let text = call.params.get("text")
485///             .and_then(|v| v.as_str())
486///             .unwrap_or("")
487///             .to_owned();
488///         Ok(Some(ToolOutput {
489///             tool_name: "echo".into(),
490///             summary: text,
491///             blocks_executed: 1,
492///             filter_stats: None,
493///             diff: None,
494///             streamed: false,
495///             terminal_id: None,
496///             locations: None,
497///             raw_response: None,
498///             claim_source: None,
499///         }))
500///     }
501/// }
502/// ```
503pub trait ToolExecutor: Send + Sync {
504    /// Parse `response` for fenced tool blocks and execute them.
505    ///
506    /// Returns `Ok(None)` when no tool blocks are found in `response`.
507    ///
508    /// # Errors
509    ///
510    /// Returns [`ToolError`] when a block is found but execution fails (blocked command,
511    /// sandbox violation, network error, timeout, etc.).
512    fn execute(
513        &self,
514        response: &str,
515    ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send;
516
517    /// Execute bypassing confirmation checks (called after user approves).
518    ///
519    /// Security controls other than the confirmation gate remain active. Default
520    /// implementation delegates to [`execute`](ToolExecutor::execute).
521    ///
522    /// # Errors
523    ///
524    /// Returns [`ToolError`] on execution failure.
525    fn execute_confirmed(
526        &self,
527        response: &str,
528    ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
529        self.execute(response)
530    }
531
532    /// Return the tool definitions this executor can handle.
533    ///
534    /// Used to populate the LLM's tool schema at context-assembly time.
535    /// Returns an empty `Vec` by default (for executors that only handle fenced blocks).
536    fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
537        vec![]
538    }
539
540    /// Execute a structured tool call. Returns `Ok(None)` if `call.tool_id` is not handled.
541    ///
542    /// # Errors
543    ///
544    /// Returns [`ToolError`] when the tool ID is handled but execution fails.
545    fn execute_tool_call(
546        &self,
547        _call: &ToolCall,
548    ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
549        std::future::ready(Ok(None))
550    }
551
552    /// Execute a structured tool call bypassing confirmation checks.
553    ///
554    /// Called after the user has explicitly approved the tool invocation.
555    /// Default implementation delegates to [`execute_tool_call`](ToolExecutor::execute_tool_call).
556    ///
557    /// # Errors
558    ///
559    /// Returns [`ToolError`] on execution failure.
560    fn execute_tool_call_confirmed(
561        &self,
562        call: &ToolCall,
563    ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
564        self.execute_tool_call(call)
565    }
566
567    /// Inject environment variables for the currently active skill. No-op by default.
568    ///
569    /// Called by the agent loop before each turn when the active skill specifies env vars.
570    /// Implementations that ignore this (e.g. `WebScrapeExecutor`) may leave the default.
571    fn set_skill_env(&self, _env: Option<std::collections::HashMap<String, String>>) {}
572
573    /// Set the effective trust level for the currently active skill. No-op by default.
574    ///
575    /// Trust level affects which operations are permitted (e.g. network access, file writes).
576    fn set_effective_trust(&self, _level: crate::SkillTrustLevel) {}
577
578    /// Whether the executor can safely retry this tool call on a transient error.
579    ///
580    /// Only idempotent operations (e.g. read-only HTTP GET) should return `true`.
581    /// Shell commands and other non-idempotent operations must keep the default `false`
582    /// to prevent double-execution of side-effectful commands.
583    fn is_tool_retryable(&self, _tool_id: &str) -> bool {
584        false
585    }
586}
587
588/// Object-safe erased version of [`ToolExecutor`] using boxed futures.
589///
590/// Because [`ToolExecutor`] uses `impl Future` return types, it is not object-safe and
591/// cannot be used as `dyn ToolExecutor`. This trait provides the same interface with
592/// `Pin<Box<dyn Future>>` returns, enabling dynamic dispatch.
593///
594/// Implemented automatically for all `T: ToolExecutor + 'static` via the blanket impl below.
595/// Use [`DynExecutor`] or `Box<dyn ErasedToolExecutor>` when runtime polymorphism is needed.
596pub trait ErasedToolExecutor: Send + Sync {
597    fn execute_erased<'a>(
598        &'a self,
599        response: &'a str,
600    ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>;
601
602    fn execute_confirmed_erased<'a>(
603        &'a self,
604        response: &'a str,
605    ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>;
606
607    fn tool_definitions_erased(&self) -> Vec<crate::registry::ToolDef>;
608
609    fn execute_tool_call_erased<'a>(
610        &'a self,
611        call: &'a ToolCall,
612    ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>;
613
614    fn execute_tool_call_confirmed_erased<'a>(
615        &'a self,
616        call: &'a ToolCall,
617    ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
618    {
619        // TrustGateExecutor overrides ToolExecutor::execute_tool_call_confirmed; the blanket
620        // impl for T: ToolExecutor routes this call through it via execute_tool_call_confirmed_erased.
621        // Other implementors fall back to execute_tool_call_erased (normal enforcement path).
622        self.execute_tool_call_erased(call)
623    }
624
625    /// Inject environment variables for the currently active skill. No-op by default.
626    fn set_skill_env(&self, _env: Option<std::collections::HashMap<String, String>>) {}
627
628    /// Set the effective trust level for the currently active skill. No-op by default.
629    fn set_effective_trust(&self, _level: crate::SkillTrustLevel) {}
630
631    /// Whether the executor can safely retry this tool call on a transient error.
632    fn is_tool_retryable_erased(&self, tool_id: &str) -> bool;
633}
634
635impl<T: ToolExecutor> ErasedToolExecutor for T {
636    fn execute_erased<'a>(
637        &'a self,
638        response: &'a str,
639    ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
640    {
641        Box::pin(self.execute(response))
642    }
643
644    fn execute_confirmed_erased<'a>(
645        &'a self,
646        response: &'a str,
647    ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
648    {
649        Box::pin(self.execute_confirmed(response))
650    }
651
652    fn tool_definitions_erased(&self) -> Vec<crate::registry::ToolDef> {
653        self.tool_definitions()
654    }
655
656    fn execute_tool_call_erased<'a>(
657        &'a self,
658        call: &'a ToolCall,
659    ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
660    {
661        Box::pin(self.execute_tool_call(call))
662    }
663
664    fn execute_tool_call_confirmed_erased<'a>(
665        &'a self,
666        call: &'a ToolCall,
667    ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
668    {
669        Box::pin(self.execute_tool_call_confirmed(call))
670    }
671
672    fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
673        ToolExecutor::set_skill_env(self, env);
674    }
675
676    fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
677        ToolExecutor::set_effective_trust(self, level);
678    }
679
680    fn is_tool_retryable_erased(&self, tool_id: &str) -> bool {
681        ToolExecutor::is_tool_retryable(self, tool_id)
682    }
683}
684
685/// Wraps `Arc<dyn ErasedToolExecutor>` so it can be used as a concrete `ToolExecutor`.
686///
687/// Enables dynamic composition of tool executors at runtime without static type chains.
688pub struct DynExecutor(pub std::sync::Arc<dyn ErasedToolExecutor>);
689
690impl ToolExecutor for DynExecutor {
691    fn execute(
692        &self,
693        response: &str,
694    ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
695        // Clone data to satisfy the 'static-ish bound: erased futures must not borrow self.
696        let inner = std::sync::Arc::clone(&self.0);
697        let response = response.to_owned();
698        async move { inner.execute_erased(&response).await }
699    }
700
701    fn execute_confirmed(
702        &self,
703        response: &str,
704    ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
705        let inner = std::sync::Arc::clone(&self.0);
706        let response = response.to_owned();
707        async move { inner.execute_confirmed_erased(&response).await }
708    }
709
710    fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
711        self.0.tool_definitions_erased()
712    }
713
714    fn execute_tool_call(
715        &self,
716        call: &ToolCall,
717    ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
718        let inner = std::sync::Arc::clone(&self.0);
719        let call = call.clone();
720        async move { inner.execute_tool_call_erased(&call).await }
721    }
722
723    fn execute_tool_call_confirmed(
724        &self,
725        call: &ToolCall,
726    ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
727        let inner = std::sync::Arc::clone(&self.0);
728        let call = call.clone();
729        async move { inner.execute_tool_call_confirmed_erased(&call).await }
730    }
731
732    fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
733        ErasedToolExecutor::set_skill_env(self.0.as_ref(), env);
734    }
735
736    fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
737        ErasedToolExecutor::set_effective_trust(self.0.as_ref(), level);
738    }
739
740    fn is_tool_retryable(&self, tool_id: &str) -> bool {
741        self.0.is_tool_retryable_erased(tool_id)
742    }
743}
744
745/// Extract fenced code blocks with the given language marker from text.
746///
747/// Searches for `` ```{lang} `` … `` ``` `` pairs, returning trimmed content.
748#[must_use]
749pub fn extract_fenced_blocks<'a>(text: &'a str, lang: &str) -> Vec<&'a str> {
750    let marker = format!("```{lang}");
751    let marker_len = marker.len();
752    let mut blocks = Vec::new();
753    let mut rest = text;
754
755    let mut search_from = 0;
756    while let Some(rel) = rest[search_from..].find(&marker) {
757        let start = search_from + rel;
758        let after = &rest[start + marker_len..];
759        // Word-boundary check: the character immediately after the marker must be
760        // whitespace, end-of-string, or a non-word character (not alphanumeric / _ / -).
761        // This prevents "```bash" from matching "```bashrc".
762        let boundary_ok = after
763            .chars()
764            .next()
765            .is_none_or(|c| !c.is_alphanumeric() && c != '_' && c != '-');
766        if !boundary_ok {
767            search_from = start + marker_len;
768            continue;
769        }
770        if let Some(end) = after.find("```") {
771            blocks.push(after[..end].trim());
772            rest = &after[end + 3..];
773            search_from = 0;
774        } else {
775            break;
776        }
777    }
778
779    blocks
780}
781
782#[cfg(test)]
783mod tests {
784    use super::*;
785
786    #[test]
787    fn tool_output_display() {
788        let output = ToolOutput {
789            tool_name: ToolName::new("bash"),
790            summary: "$ echo hello\nhello".to_owned(),
791            blocks_executed: 1,
792            filter_stats: None,
793            diff: None,
794            streamed: false,
795            terminal_id: None,
796            locations: None,
797            raw_response: None,
798            claim_source: None,
799        };
800        assert_eq!(output.to_string(), "$ echo hello\nhello");
801    }
802
803    #[test]
804    fn tool_error_blocked_display() {
805        let err = ToolError::Blocked {
806            command: "rm -rf /".to_owned(),
807        };
808        assert_eq!(err.to_string(), "command blocked by policy: rm -rf /");
809    }
810
811    #[test]
812    fn tool_error_sandbox_violation_display() {
813        let err = ToolError::SandboxViolation {
814            path: "/etc/shadow".to_owned(),
815        };
816        assert_eq!(err.to_string(), "path not allowed by sandbox: /etc/shadow");
817    }
818
819    #[test]
820    fn tool_error_confirmation_required_display() {
821        let err = ToolError::ConfirmationRequired {
822            command: "rm -rf /tmp".to_owned(),
823        };
824        assert_eq!(
825            err.to_string(),
826            "command requires confirmation: rm -rf /tmp"
827        );
828    }
829
830    #[test]
831    fn tool_error_timeout_display() {
832        let err = ToolError::Timeout { timeout_secs: 30 };
833        assert_eq!(err.to_string(), "command timed out after 30s");
834    }
835
836    #[test]
837    fn tool_error_invalid_params_display() {
838        let err = ToolError::InvalidParams {
839            message: "missing field `command`".to_owned(),
840        };
841        assert_eq!(
842            err.to_string(),
843            "invalid tool parameters: missing field `command`"
844        );
845    }
846
847    #[test]
848    fn deserialize_params_valid() {
849        #[derive(Debug, serde::Deserialize, PartialEq)]
850        struct P {
851            name: String,
852            count: u32,
853        }
854        let mut map = serde_json::Map::new();
855        map.insert("name".to_owned(), serde_json::json!("test"));
856        map.insert("count".to_owned(), serde_json::json!(42));
857        let p: P = deserialize_params(&map).unwrap();
858        assert_eq!(
859            p,
860            P {
861                name: "test".to_owned(),
862                count: 42
863            }
864        );
865    }
866
867    #[test]
868    fn deserialize_params_missing_required_field() {
869        #[derive(Debug, serde::Deserialize)]
870        #[allow(dead_code)]
871        struct P {
872            name: String,
873        }
874        let map = serde_json::Map::new();
875        let err = deserialize_params::<P>(&map).unwrap_err();
876        assert!(matches!(err, ToolError::InvalidParams { .. }));
877    }
878
879    #[test]
880    fn deserialize_params_wrong_type() {
881        #[derive(Debug, serde::Deserialize)]
882        #[allow(dead_code)]
883        struct P {
884            count: u32,
885        }
886        let mut map = serde_json::Map::new();
887        map.insert("count".to_owned(), serde_json::json!("not a number"));
888        let err = deserialize_params::<P>(&map).unwrap_err();
889        assert!(matches!(err, ToolError::InvalidParams { .. }));
890    }
891
892    #[test]
893    fn deserialize_params_all_optional_empty() {
894        #[derive(Debug, serde::Deserialize, PartialEq)]
895        struct P {
896            name: Option<String>,
897        }
898        let map = serde_json::Map::new();
899        let p: P = deserialize_params(&map).unwrap();
900        assert_eq!(p, P { name: None });
901    }
902
903    #[test]
904    fn deserialize_params_ignores_extra_fields() {
905        #[derive(Debug, serde::Deserialize, PartialEq)]
906        struct P {
907            name: String,
908        }
909        let mut map = serde_json::Map::new();
910        map.insert("name".to_owned(), serde_json::json!("test"));
911        map.insert("extra".to_owned(), serde_json::json!(true));
912        let p: P = deserialize_params(&map).unwrap();
913        assert_eq!(
914            p,
915            P {
916                name: "test".to_owned()
917            }
918        );
919    }
920
921    #[test]
922    fn tool_error_execution_display() {
923        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "bash not found");
924        let err = ToolError::Execution(io_err);
925        assert!(err.to_string().starts_with("execution failed:"));
926        assert!(err.to_string().contains("bash not found"));
927    }
928
929    // ErrorKind classification tests
930    #[test]
931    fn error_kind_timeout_is_transient() {
932        let err = ToolError::Timeout { timeout_secs: 30 };
933        assert_eq!(err.kind(), ErrorKind::Transient);
934    }
935
936    #[test]
937    fn error_kind_blocked_is_permanent() {
938        let err = ToolError::Blocked {
939            command: "rm -rf /".to_owned(),
940        };
941        assert_eq!(err.kind(), ErrorKind::Permanent);
942    }
943
944    #[test]
945    fn error_kind_sandbox_violation_is_permanent() {
946        let err = ToolError::SandboxViolation {
947            path: "/etc/shadow".to_owned(),
948        };
949        assert_eq!(err.kind(), ErrorKind::Permanent);
950    }
951
952    #[test]
953    fn error_kind_cancelled_is_permanent() {
954        assert_eq!(ToolError::Cancelled.kind(), ErrorKind::Permanent);
955    }
956
957    #[test]
958    fn error_kind_invalid_params_is_permanent() {
959        let err = ToolError::InvalidParams {
960            message: "bad arg".to_owned(),
961        };
962        assert_eq!(err.kind(), ErrorKind::Permanent);
963    }
964
965    #[test]
966    fn error_kind_confirmation_required_is_permanent() {
967        let err = ToolError::ConfirmationRequired {
968            command: "rm /tmp/x".to_owned(),
969        };
970        assert_eq!(err.kind(), ErrorKind::Permanent);
971    }
972
973    #[test]
974    fn error_kind_execution_timed_out_is_transient() {
975        let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "timeout");
976        assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
977    }
978
979    #[test]
980    fn error_kind_execution_interrupted_is_transient() {
981        let io_err = std::io::Error::new(std::io::ErrorKind::Interrupted, "interrupted");
982        assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
983    }
984
985    #[test]
986    fn error_kind_execution_connection_reset_is_transient() {
987        let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "reset");
988        assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
989    }
990
991    #[test]
992    fn error_kind_execution_broken_pipe_is_transient() {
993        let io_err = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pipe broken");
994        assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
995    }
996
997    #[test]
998    fn error_kind_execution_would_block_is_transient() {
999        let io_err = std::io::Error::new(std::io::ErrorKind::WouldBlock, "would block");
1000        assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
1001    }
1002
1003    #[test]
1004    fn error_kind_execution_connection_aborted_is_transient() {
1005        let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionAborted, "aborted");
1006        assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
1007    }
1008
1009    #[test]
1010    fn error_kind_execution_not_found_is_permanent() {
1011        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "not found");
1012        assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
1013    }
1014
1015    #[test]
1016    fn error_kind_execution_permission_denied_is_permanent() {
1017        let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
1018        assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
1019    }
1020
1021    #[test]
1022    fn error_kind_execution_other_is_permanent() {
1023        let io_err = std::io::Error::other("some other error");
1024        assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
1025    }
1026
1027    #[test]
1028    fn error_kind_execution_already_exists_is_permanent() {
1029        let io_err = std::io::Error::new(std::io::ErrorKind::AlreadyExists, "exists");
1030        assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
1031    }
1032
1033    #[test]
1034    fn error_kind_display() {
1035        assert_eq!(ErrorKind::Transient.to_string(), "transient");
1036        assert_eq!(ErrorKind::Permanent.to_string(), "permanent");
1037    }
1038
1039    #[test]
1040    fn truncate_tool_output_short_passthrough() {
1041        let short = "hello world";
1042        assert_eq!(truncate_tool_output(short), short);
1043    }
1044
1045    #[test]
1046    fn truncate_tool_output_exact_limit() {
1047        let exact = "a".repeat(MAX_TOOL_OUTPUT_CHARS);
1048        assert_eq!(truncate_tool_output(&exact), exact);
1049    }
1050
1051    #[test]
1052    fn truncate_tool_output_long_split() {
1053        let long = "x".repeat(MAX_TOOL_OUTPUT_CHARS + 1000);
1054        let result = truncate_tool_output(&long);
1055        assert!(result.contains("truncated"));
1056        assert!(result.len() < long.len());
1057    }
1058
1059    #[test]
1060    fn truncate_tool_output_notice_contains_count() {
1061        let long = "y".repeat(MAX_TOOL_OUTPUT_CHARS + 2000);
1062        let result = truncate_tool_output(&long);
1063        assert!(result.contains("truncated"));
1064        assert!(result.contains("chars"));
1065    }
1066
1067    #[derive(Debug)]
1068    struct DefaultExecutor;
1069    impl ToolExecutor for DefaultExecutor {
1070        async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
1071            Ok(None)
1072        }
1073    }
1074
1075    #[tokio::test]
1076    async fn execute_tool_call_default_returns_none() {
1077        let exec = DefaultExecutor;
1078        let call = ToolCall {
1079            tool_id: ToolName::new("anything"),
1080            params: serde_json::Map::new(),
1081            caller_id: None,
1082        };
1083        let result = exec.execute_tool_call(&call).await.unwrap();
1084        assert!(result.is_none());
1085    }
1086
1087    #[test]
1088    fn filter_stats_savings_pct() {
1089        let fs = FilterStats {
1090            raw_chars: 1000,
1091            filtered_chars: 200,
1092            ..Default::default()
1093        };
1094        assert!((fs.savings_pct() - 80.0).abs() < 0.01);
1095    }
1096
1097    #[test]
1098    fn filter_stats_savings_pct_zero() {
1099        let fs = FilterStats::default();
1100        assert!((fs.savings_pct()).abs() < 0.01);
1101    }
1102
1103    #[test]
1104    fn filter_stats_estimated_tokens_saved() {
1105        let fs = FilterStats {
1106            raw_chars: 1000,
1107            filtered_chars: 200,
1108            ..Default::default()
1109        };
1110        assert_eq!(fs.estimated_tokens_saved(), 200); // (1000 - 200) / 4
1111    }
1112
1113    #[test]
1114    fn filter_stats_format_inline() {
1115        let fs = FilterStats {
1116            raw_chars: 1000,
1117            filtered_chars: 200,
1118            raw_lines: 342,
1119            filtered_lines: 28,
1120            ..Default::default()
1121        };
1122        let line = fs.format_inline("shell");
1123        assert_eq!(line, "[shell] 342 lines \u{2192} 28 lines, 80.0% filtered");
1124    }
1125
1126    #[test]
1127    fn filter_stats_format_inline_zero() {
1128        let fs = FilterStats::default();
1129        let line = fs.format_inline("bash");
1130        assert_eq!(line, "[bash] 0 lines \u{2192} 0 lines, 0.0% filtered");
1131    }
1132
1133    // DynExecutor tests
1134
1135    struct FixedExecutor {
1136        tool_id: &'static str,
1137        output: &'static str,
1138    }
1139
1140    impl ToolExecutor for FixedExecutor {
1141        async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
1142            Ok(Some(ToolOutput {
1143                tool_name: ToolName::new(self.tool_id),
1144                summary: self.output.to_owned(),
1145                blocks_executed: 1,
1146                filter_stats: None,
1147                diff: None,
1148                streamed: false,
1149                terminal_id: None,
1150                locations: None,
1151                raw_response: None,
1152                claim_source: None,
1153            }))
1154        }
1155
1156        fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
1157            vec![]
1158        }
1159
1160        async fn execute_tool_call(
1161            &self,
1162            _call: &ToolCall,
1163        ) -> Result<Option<ToolOutput>, ToolError> {
1164            Ok(Some(ToolOutput {
1165                tool_name: ToolName::new(self.tool_id),
1166                summary: self.output.to_owned(),
1167                blocks_executed: 1,
1168                filter_stats: None,
1169                diff: None,
1170                streamed: false,
1171                terminal_id: None,
1172                locations: None,
1173                raw_response: None,
1174                claim_source: None,
1175            }))
1176        }
1177    }
1178
1179    #[tokio::test]
1180    async fn dyn_executor_execute_delegates() {
1181        let inner = std::sync::Arc::new(FixedExecutor {
1182            tool_id: "bash",
1183            output: "hello",
1184        });
1185        let exec = DynExecutor(inner);
1186        let result = exec.execute("```bash\necho hello\n```").await.unwrap();
1187        assert!(result.is_some());
1188        assert_eq!(result.unwrap().summary, "hello");
1189    }
1190
1191    #[tokio::test]
1192    async fn dyn_executor_execute_confirmed_delegates() {
1193        let inner = std::sync::Arc::new(FixedExecutor {
1194            tool_id: "bash",
1195            output: "confirmed",
1196        });
1197        let exec = DynExecutor(inner);
1198        let result = exec.execute_confirmed("...").await.unwrap();
1199        assert!(result.is_some());
1200        assert_eq!(result.unwrap().summary, "confirmed");
1201    }
1202
1203    #[test]
1204    fn dyn_executor_tool_definitions_delegates() {
1205        let inner = std::sync::Arc::new(FixedExecutor {
1206            tool_id: "my_tool",
1207            output: "",
1208        });
1209        let exec = DynExecutor(inner);
1210        // FixedExecutor returns empty definitions; verify delegation occurs without panic.
1211        let defs = exec.tool_definitions();
1212        assert!(defs.is_empty());
1213    }
1214
1215    #[tokio::test]
1216    async fn dyn_executor_execute_tool_call_delegates() {
1217        let inner = std::sync::Arc::new(FixedExecutor {
1218            tool_id: "bash",
1219            output: "tool_call_result",
1220        });
1221        let exec = DynExecutor(inner);
1222        let call = ToolCall {
1223            tool_id: ToolName::new("bash"),
1224            params: serde_json::Map::new(),
1225            caller_id: None,
1226        };
1227        let result = exec.execute_tool_call(&call).await.unwrap();
1228        assert!(result.is_some());
1229        assert_eq!(result.unwrap().summary, "tool_call_result");
1230    }
1231
1232    #[test]
1233    fn dyn_executor_set_effective_trust_delegates() {
1234        use std::sync::atomic::{AtomicU8, Ordering};
1235
1236        struct TrustCapture(AtomicU8);
1237        impl ToolExecutor for TrustCapture {
1238            async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
1239                Ok(None)
1240            }
1241            fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
1242                // encode: Trusted=0, Verified=1, Quarantined=2, Blocked=3
1243                let v = match level {
1244                    crate::SkillTrustLevel::Trusted => 0u8,
1245                    crate::SkillTrustLevel::Verified => 1,
1246                    crate::SkillTrustLevel::Quarantined => 2,
1247                    crate::SkillTrustLevel::Blocked => 3,
1248                };
1249                self.0.store(v, Ordering::Relaxed);
1250            }
1251        }
1252
1253        let inner = std::sync::Arc::new(TrustCapture(AtomicU8::new(0)));
1254        let exec =
1255            DynExecutor(std::sync::Arc::clone(&inner) as std::sync::Arc<dyn ErasedToolExecutor>);
1256        ToolExecutor::set_effective_trust(&exec, crate::SkillTrustLevel::Quarantined);
1257        assert_eq!(inner.0.load(Ordering::Relaxed), 2);
1258
1259        ToolExecutor::set_effective_trust(&exec, crate::SkillTrustLevel::Blocked);
1260        assert_eq!(inner.0.load(Ordering::Relaxed), 3);
1261    }
1262
1263    #[test]
1264    fn extract_fenced_blocks_no_prefix_match() {
1265        // ```bashrc must NOT match when searching for "bash"
1266        assert!(extract_fenced_blocks("```bashrc\nfoo\n```", "bash").is_empty());
1267        // exact match
1268        assert_eq!(
1269            extract_fenced_blocks("```bash\nfoo\n```", "bash"),
1270            vec!["foo"]
1271        );
1272        // trailing space is fine
1273        assert_eq!(
1274            extract_fenced_blocks("```bash \nfoo\n```", "bash"),
1275            vec!["foo"]
1276        );
1277    }
1278
1279    // ── ToolError::category() delegation tests ────────────────────────────────
1280
1281    #[test]
1282    fn tool_error_http_400_category_is_invalid_parameters() {
1283        use crate::error_taxonomy::ToolErrorCategory;
1284        let err = ToolError::Http {
1285            status: 400,
1286            message: "bad request".to_owned(),
1287        };
1288        assert_eq!(err.category(), ToolErrorCategory::InvalidParameters);
1289    }
1290
1291    #[test]
1292    fn tool_error_http_401_category_is_policy_blocked() {
1293        use crate::error_taxonomy::ToolErrorCategory;
1294        let err = ToolError::Http {
1295            status: 401,
1296            message: "unauthorized".to_owned(),
1297        };
1298        assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1299    }
1300
1301    #[test]
1302    fn tool_error_http_403_category_is_policy_blocked() {
1303        use crate::error_taxonomy::ToolErrorCategory;
1304        let err = ToolError::Http {
1305            status: 403,
1306            message: "forbidden".to_owned(),
1307        };
1308        assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1309    }
1310
1311    #[test]
1312    fn tool_error_http_404_category_is_permanent_failure() {
1313        use crate::error_taxonomy::ToolErrorCategory;
1314        let err = ToolError::Http {
1315            status: 404,
1316            message: "not found".to_owned(),
1317        };
1318        assert_eq!(err.category(), ToolErrorCategory::PermanentFailure);
1319    }
1320
1321    #[test]
1322    fn tool_error_http_429_category_is_rate_limited() {
1323        use crate::error_taxonomy::ToolErrorCategory;
1324        let err = ToolError::Http {
1325            status: 429,
1326            message: "too many requests".to_owned(),
1327        };
1328        assert_eq!(err.category(), ToolErrorCategory::RateLimited);
1329    }
1330
1331    #[test]
1332    fn tool_error_http_500_category_is_server_error() {
1333        use crate::error_taxonomy::ToolErrorCategory;
1334        let err = ToolError::Http {
1335            status: 500,
1336            message: "internal server error".to_owned(),
1337        };
1338        assert_eq!(err.category(), ToolErrorCategory::ServerError);
1339    }
1340
1341    #[test]
1342    fn tool_error_http_502_category_is_server_error() {
1343        use crate::error_taxonomy::ToolErrorCategory;
1344        let err = ToolError::Http {
1345            status: 502,
1346            message: "bad gateway".to_owned(),
1347        };
1348        assert_eq!(err.category(), ToolErrorCategory::ServerError);
1349    }
1350
1351    #[test]
1352    fn tool_error_http_503_category_is_server_error() {
1353        use crate::error_taxonomy::ToolErrorCategory;
1354        let err = ToolError::Http {
1355            status: 503,
1356            message: "service unavailable".to_owned(),
1357        };
1358        assert_eq!(err.category(), ToolErrorCategory::ServerError);
1359    }
1360
1361    #[test]
1362    fn tool_error_http_503_is_transient_triggers_phase2_retry() {
1363        // Phase 2 retry fires when err.kind() == ErrorKind::Transient.
1364        // Verify the full chain: Http{503} -> ServerError -> is_retryable() -> Transient.
1365        let err = ToolError::Http {
1366            status: 503,
1367            message: "service unavailable".to_owned(),
1368        };
1369        assert_eq!(
1370            err.kind(),
1371            ErrorKind::Transient,
1372            "HTTP 503 must be Transient so Phase 2 retry fires"
1373        );
1374    }
1375
1376    #[test]
1377    fn tool_error_blocked_category_is_policy_blocked() {
1378        use crate::error_taxonomy::ToolErrorCategory;
1379        let err = ToolError::Blocked {
1380            command: "rm -rf /".to_owned(),
1381        };
1382        assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1383    }
1384
1385    #[test]
1386    fn tool_error_sandbox_violation_category_is_policy_blocked() {
1387        use crate::error_taxonomy::ToolErrorCategory;
1388        let err = ToolError::SandboxViolation {
1389            path: "/etc/shadow".to_owned(),
1390        };
1391        assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1392    }
1393
1394    #[test]
1395    fn tool_error_confirmation_required_category() {
1396        use crate::error_taxonomy::ToolErrorCategory;
1397        let err = ToolError::ConfirmationRequired {
1398            command: "rm /tmp/x".to_owned(),
1399        };
1400        assert_eq!(err.category(), ToolErrorCategory::ConfirmationRequired);
1401    }
1402
1403    #[test]
1404    fn tool_error_timeout_category() {
1405        use crate::error_taxonomy::ToolErrorCategory;
1406        let err = ToolError::Timeout { timeout_secs: 30 };
1407        assert_eq!(err.category(), ToolErrorCategory::Timeout);
1408    }
1409
1410    #[test]
1411    fn tool_error_cancelled_category() {
1412        use crate::error_taxonomy::ToolErrorCategory;
1413        assert_eq!(
1414            ToolError::Cancelled.category(),
1415            ToolErrorCategory::Cancelled
1416        );
1417    }
1418
1419    #[test]
1420    fn tool_error_invalid_params_category() {
1421        use crate::error_taxonomy::ToolErrorCategory;
1422        let err = ToolError::InvalidParams {
1423            message: "missing field".to_owned(),
1424        };
1425        assert_eq!(err.category(), ToolErrorCategory::InvalidParameters);
1426    }
1427
1428    // B2 regression: Execution(NotFound) must NOT produce ToolNotFound.
1429    #[test]
1430    fn tool_error_execution_not_found_category_is_permanent_failure() {
1431        use crate::error_taxonomy::ToolErrorCategory;
1432        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "bash: not found");
1433        let err = ToolError::Execution(io_err);
1434        let cat = err.category();
1435        assert_ne!(
1436            cat,
1437            ToolErrorCategory::ToolNotFound,
1438            "Execution(NotFound) must NOT map to ToolNotFound"
1439        );
1440        assert_eq!(cat, ToolErrorCategory::PermanentFailure);
1441    }
1442
1443    #[test]
1444    fn tool_error_execution_timed_out_category_is_timeout() {
1445        use crate::error_taxonomy::ToolErrorCategory;
1446        let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "timed out");
1447        assert_eq!(
1448            ToolError::Execution(io_err).category(),
1449            ToolErrorCategory::Timeout
1450        );
1451    }
1452
1453    #[test]
1454    fn tool_error_execution_connection_refused_category_is_network_error() {
1455        use crate::error_taxonomy::ToolErrorCategory;
1456        let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
1457        assert_eq!(
1458            ToolError::Execution(io_err).category(),
1459            ToolErrorCategory::NetworkError
1460        );
1461    }
1462
1463    // B4 regression: Http/network/transient categories must NOT be quality failures.
1464    #[test]
1465    fn b4_tool_error_http_429_not_quality_failure() {
1466        let err = ToolError::Http {
1467            status: 429,
1468            message: "rate limited".to_owned(),
1469        };
1470        assert!(
1471            !err.category().is_quality_failure(),
1472            "RateLimited must not be a quality failure"
1473        );
1474    }
1475
1476    #[test]
1477    fn b4_tool_error_http_503_not_quality_failure() {
1478        let err = ToolError::Http {
1479            status: 503,
1480            message: "service unavailable".to_owned(),
1481        };
1482        assert!(
1483            !err.category().is_quality_failure(),
1484            "ServerError must not be a quality failure"
1485        );
1486    }
1487
1488    #[test]
1489    fn b4_tool_error_execution_timed_out_not_quality_failure() {
1490        let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "timeout");
1491        assert!(
1492            !ToolError::Execution(io_err).category().is_quality_failure(),
1493            "Timeout must not be a quality failure"
1494        );
1495    }
1496
1497    // ── ToolError::Shell category tests ──────────────────────────────────────
1498
1499    #[test]
1500    fn tool_error_shell_exit126_is_policy_blocked() {
1501        use crate::error_taxonomy::ToolErrorCategory;
1502        let err = ToolError::Shell {
1503            exit_code: 126,
1504            category: ToolErrorCategory::PolicyBlocked,
1505            message: "permission denied".to_owned(),
1506        };
1507        assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1508    }
1509
1510    #[test]
1511    fn tool_error_shell_exit127_is_permanent_failure() {
1512        use crate::error_taxonomy::ToolErrorCategory;
1513        let err = ToolError::Shell {
1514            exit_code: 127,
1515            category: ToolErrorCategory::PermanentFailure,
1516            message: "command not found".to_owned(),
1517        };
1518        assert_eq!(err.category(), ToolErrorCategory::PermanentFailure);
1519        assert!(!err.category().is_retryable());
1520    }
1521
1522    #[test]
1523    fn tool_error_shell_not_quality_failure() {
1524        use crate::error_taxonomy::ToolErrorCategory;
1525        let err = ToolError::Shell {
1526            exit_code: 127,
1527            category: ToolErrorCategory::PermanentFailure,
1528            message: "command not found".to_owned(),
1529        };
1530        // Shell exit errors are not attributable to LLM output quality.
1531        assert!(!err.category().is_quality_failure());
1532    }
1533}