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        /// Active sandbox profile, if any. `None` when sandbox is disabled.
289        sandbox_profile: Option<String>,
290    },
291    /// A chunk of streaming output was produced (e.g. from a long-running command).
292    OutputChunk {
293        tool_name: ToolName,
294        command: String,
295        chunk: String,
296    },
297    /// The tool finished. Contains the full output and optional filter/diff data.
298    Completed {
299        tool_name: ToolName,
300        command: String,
301        /// Full output text (possibly filtered and truncated).
302        output: String,
303        /// `true` when the tool exited successfully, `false` on error.
304        success: bool,
305        filter_stats: Option<FilterStats>,
306        diff: Option<DiffData>,
307    },
308    /// A transactional rollback was performed, restoring or deleting files.
309    Rollback {
310        tool_name: ToolName,
311        command: String,
312        /// Number of files restored to their pre-execution content.
313        restored_count: usize,
314        /// Number of files that did not exist before execution and were deleted.
315        deleted_count: usize,
316    },
317}
318
319/// Sender half of the unbounded channel used to stream [`ToolEvent`]s to the UI.
320///
321/// Obtained from [`tokio::sync::mpsc::unbounded_channel`] and injected into executors
322/// via builder methods (e.g. [`ShellExecutor::with_tool_event_tx`](crate::ShellExecutor)).
323pub type ToolEventTx = tokio::sync::mpsc::UnboundedSender<ToolEvent>;
324
325/// Classifies a tool error as transient (retryable) or permanent (abort immediately).
326///
327/// Transient errors may succeed on retry (network blips, race conditions).
328/// Permanent errors will not succeed regardless of retries (policy, bad args, not found).
329#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
330pub enum ErrorKind {
331    Transient,
332    Permanent,
333}
334
335impl std::fmt::Display for ErrorKind {
336    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
337        match self {
338            Self::Transient => f.write_str("transient"),
339            Self::Permanent => f.write_str("permanent"),
340        }
341    }
342}
343
344/// Errors that can occur during tool execution.
345#[derive(Debug, thiserror::Error)]
346pub enum ToolError {
347    #[error("command blocked by policy: {command}")]
348    Blocked { command: String },
349
350    #[error("path not allowed by sandbox: {path}")]
351    SandboxViolation { path: String },
352
353    #[error("command requires confirmation: {command}")]
354    ConfirmationRequired { command: String },
355
356    #[error("command timed out after {timeout_secs}s")]
357    Timeout { timeout_secs: u64 },
358
359    #[error("operation cancelled")]
360    Cancelled,
361
362    #[error("invalid tool parameters: {message}")]
363    InvalidParams { message: String },
364
365    #[error("execution failed: {0}")]
366    Execution(#[from] std::io::Error),
367
368    /// HTTP or API error with status code for fine-grained classification.
369    ///
370    /// Used by `WebScrapeExecutor` and other HTTP-based tools to preserve the status
371    /// code for taxonomy classification. Scope: HTTP tools only (MCP uses a separate path).
372    #[error("HTTP error {status}: {message}")]
373    Http { status: u16, message: String },
374
375    /// Shell execution error with explicit exit code and pre-classified category.
376    ///
377    /// Used by `ShellExecutor` when the exit code or stderr content maps to a known
378    /// taxonomy category (e.g., exit 126 → `PolicyBlocked`, exit 127 → `PermanentFailure`).
379    /// Preserves the exit code for audit logging and the category for skill evolution.
380    #[error("shell error (exit {exit_code}): {message}")]
381    Shell {
382        exit_code: i32,
383        category: crate::error_taxonomy::ToolErrorCategory,
384        message: String,
385    },
386
387    #[error("snapshot failed: {reason}")]
388    SnapshotFailed { reason: String },
389}
390
391impl ToolError {
392    /// Fine-grained error classification using the 12-category taxonomy.
393    ///
394    /// Prefer `category()` over `kind()` for new code. `kind()` is preserved for
395    /// backward compatibility and delegates to `category().error_kind()`.
396    #[must_use]
397    pub fn category(&self) -> crate::error_taxonomy::ToolErrorCategory {
398        use crate::error_taxonomy::{ToolErrorCategory, classify_http_status, classify_io_error};
399        match self {
400            Self::Blocked { .. } | Self::SandboxViolation { .. } => {
401                ToolErrorCategory::PolicyBlocked
402            }
403            Self::ConfirmationRequired { .. } => ToolErrorCategory::ConfirmationRequired,
404            Self::Timeout { .. } => ToolErrorCategory::Timeout,
405            Self::Cancelled => ToolErrorCategory::Cancelled,
406            Self::InvalidParams { .. } => ToolErrorCategory::InvalidParameters,
407            Self::Http { status, .. } => classify_http_status(*status),
408            Self::Execution(io_err) => classify_io_error(io_err),
409            Self::Shell { category, .. } => *category,
410            Self::SnapshotFailed { .. } => ToolErrorCategory::PermanentFailure,
411        }
412    }
413
414    /// Coarse classification for backward compatibility. Delegates to `category().error_kind()`.
415    ///
416    /// For `Execution(io::Error)`, the classification inspects `io::Error::kind()`:
417    /// - Transient: `TimedOut`, `WouldBlock`, `Interrupted`, `ConnectionReset`,
418    ///   `ConnectionAborted`, `BrokenPipe` — these may succeed on retry.
419    /// - Permanent: `NotFound`, `PermissionDenied`, `AlreadyExists`, and all other
420    ///   I/O error kinds — retrying would waste time with no benefit.
421    #[must_use]
422    pub fn kind(&self) -> ErrorKind {
423        use crate::error_taxonomy::ToolErrorCategoryExt;
424        self.category().error_kind()
425    }
426}
427
428/// Deserialize tool call params from a `serde_json::Map<String, Value>` into a typed struct.
429///
430/// # Errors
431///
432/// Returns `ToolError::InvalidParams` when deserialization fails.
433pub fn deserialize_params<T: serde::de::DeserializeOwned>(
434    params: &serde_json::Map<String, serde_json::Value>,
435) -> Result<T, ToolError> {
436    let obj = serde_json::Value::Object(params.clone());
437    serde_json::from_value(obj).map_err(|e| ToolError::InvalidParams {
438        message: e.to_string(),
439    })
440}
441
442/// Async trait for tool execution backends.
443///
444/// Implementations include [`ShellExecutor`](crate::ShellExecutor),
445/// [`WebScrapeExecutor`](crate::WebScrapeExecutor), [`CompositeExecutor`](crate::CompositeExecutor),
446/// and [`FileExecutor`](crate::FileExecutor).
447///
448/// # Contract
449///
450/// - [`execute`](ToolExecutor::execute) and [`execute_tool_call`](ToolExecutor::execute_tool_call)
451///   return `Ok(None)` when the executor does not handle the given input — callers must not
452///   treat `None` as an error.
453/// - All methods must be `Send + Sync` and free of blocking I/O.
454/// - Implementations must enforce their own security controls (blocklists, sandboxes, SSRF
455///   protection) before executing any side-effectful operation.
456/// - [`execute_confirmed`](ToolExecutor::execute_confirmed) and
457///   [`execute_tool_call_confirmed`](ToolExecutor::execute_tool_call_confirmed) bypass
458///   confirmation gates only — all other security controls remain active.
459///
460/// # Two Invocation Paths
461///
462/// **Legacy fenced blocks**: The agent loop passes the raw LLM response string to [`execute`](ToolExecutor::execute).
463/// The executor parses ` ```bash ` or ` ```scrape ` blocks and executes each one.
464///
465/// **Structured tool calls**: The agent loop constructs a [`ToolCall`] from the LLM's
466/// JSON tool-use response and dispatches it via [`execute_tool_call`](ToolExecutor::execute_tool_call).
467/// This is the preferred path for new code.
468///
469/// # Example
470///
471/// ```rust
472/// use zeph_tools::{ToolExecutor, ToolCall, ToolOutput, ToolError, executor::ClaimSource};
473///
474/// #[derive(Debug)]
475/// struct EchoExecutor;
476///
477/// impl ToolExecutor for EchoExecutor {
478///     async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
479///         Ok(None) // not a fenced-block executor
480///     }
481///
482///     async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
483///         if call.tool_id != "echo" {
484///             return Ok(None);
485///         }
486///         let text = call.params.get("text")
487///             .and_then(|v| v.as_str())
488///             .unwrap_or("")
489///             .to_owned();
490///         Ok(Some(ToolOutput {
491///             tool_name: "echo".into(),
492///             summary: text,
493///             blocks_executed: 1,
494///             filter_stats: None,
495///             diff: None,
496///             streamed: false,
497///             terminal_id: None,
498///             locations: None,
499///             raw_response: None,
500///             claim_source: None,
501///         }))
502///     }
503/// }
504/// ```
505pub trait ToolExecutor: Send + Sync {
506    /// Parse `response` for fenced tool blocks and execute them.
507    ///
508    /// Returns `Ok(None)` when no tool blocks are found in `response`.
509    ///
510    /// # Errors
511    ///
512    /// Returns [`ToolError`] when a block is found but execution fails (blocked command,
513    /// sandbox violation, network error, timeout, etc.).
514    fn execute(
515        &self,
516        response: &str,
517    ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send;
518
519    /// Execute bypassing confirmation checks (called after user approves).
520    ///
521    /// Security controls other than the confirmation gate remain active. Default
522    /// implementation delegates to [`execute`](ToolExecutor::execute).
523    ///
524    /// # Errors
525    ///
526    /// Returns [`ToolError`] on execution failure.
527    fn execute_confirmed(
528        &self,
529        response: &str,
530    ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
531        self.execute(response)
532    }
533
534    /// Return the tool definitions this executor can handle.
535    ///
536    /// Used to populate the LLM's tool schema at context-assembly time.
537    /// Returns an empty `Vec` by default (for executors that only handle fenced blocks).
538    fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
539        vec![]
540    }
541
542    /// Execute a structured tool call. Returns `Ok(None)` if `call.tool_id` is not handled.
543    ///
544    /// # Errors
545    ///
546    /// Returns [`ToolError`] when the tool ID is handled but execution fails.
547    fn execute_tool_call(
548        &self,
549        _call: &ToolCall,
550    ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
551        std::future::ready(Ok(None))
552    }
553
554    /// Execute a structured tool call bypassing confirmation checks.
555    ///
556    /// Called after the user has explicitly approved the tool invocation.
557    /// Default implementation delegates to [`execute_tool_call`](ToolExecutor::execute_tool_call).
558    ///
559    /// # Errors
560    ///
561    /// Returns [`ToolError`] on execution failure.
562    fn execute_tool_call_confirmed(
563        &self,
564        call: &ToolCall,
565    ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
566        self.execute_tool_call(call)
567    }
568
569    /// Inject environment variables for the currently active skill. No-op by default.
570    ///
571    /// Called by the agent loop before each turn when the active skill specifies env vars.
572    /// Implementations that ignore this (e.g. `WebScrapeExecutor`) may leave the default.
573    fn set_skill_env(&self, _env: Option<std::collections::HashMap<String, String>>) {}
574
575    /// Set the effective trust level for the currently active skill. No-op by default.
576    ///
577    /// Trust level affects which operations are permitted (e.g. network access, file writes).
578    fn set_effective_trust(&self, _level: crate::SkillTrustLevel) {}
579
580    /// Whether the executor can safely retry this tool call on a transient error.
581    ///
582    /// Only idempotent operations (e.g. read-only HTTP GET) should return `true`.
583    /// Shell commands and other non-idempotent operations must keep the default `false`
584    /// to prevent double-execution of side-effectful commands.
585    fn is_tool_retryable(&self, _tool_id: &str) -> bool {
586        false
587    }
588
589    /// Whether a tool call can be safely dispatched speculatively (before the LLM finishes).
590    ///
591    /// Speculative execution requires the tool to be:
592    /// 1. Idempotent — repeated execution with the same args produces the same result.
593    /// 2. Side-effect-free or cheaply reversible.
594    /// 3. Not subject to user confirmation (`needs_confirmation` must be false at call time).
595    ///
596    /// Default: `false` (safe). Override to `true` only for tools that satisfy all three
597    /// properties. The engine additionally gates on trust level and confirmation status
598    /// regardless of this flag.
599    ///
600    /// # Examples
601    ///
602    /// ```rust
603    /// use zeph_tools::ToolExecutor;
604    ///
605    /// struct ReadOnlyExecutor;
606    /// impl ToolExecutor for ReadOnlyExecutor {
607    ///     async fn execute(&self, _: &str) -> Result<Option<zeph_tools::ToolOutput>, zeph_tools::ToolError> {
608    ///         Ok(None)
609    ///     }
610    ///     fn is_tool_speculatable(&self, _tool_id: &str) -> bool {
611    ///         true // read-only, idempotent
612    ///     }
613    /// }
614    /// ```
615    fn is_tool_speculatable(&self, _tool_id: &str) -> bool {
616        false
617    }
618}
619
620/// Object-safe erased version of [`ToolExecutor`] using boxed futures.
621///
622/// Because [`ToolExecutor`] uses `impl Future` return types, it is not object-safe and
623/// cannot be used as `dyn ToolExecutor`. This trait provides the same interface with
624/// `Pin<Box<dyn Future>>` returns, enabling dynamic dispatch.
625///
626/// Implemented automatically for all `T: ToolExecutor + 'static` via the blanket impl below.
627/// Use [`DynExecutor`] or `Box<dyn ErasedToolExecutor>` when runtime polymorphism is needed.
628pub trait ErasedToolExecutor: Send + Sync {
629    fn execute_erased<'a>(
630        &'a self,
631        response: &'a str,
632    ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>;
633
634    fn execute_confirmed_erased<'a>(
635        &'a self,
636        response: &'a str,
637    ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>;
638
639    fn tool_definitions_erased(&self) -> Vec<crate::registry::ToolDef>;
640
641    fn execute_tool_call_erased<'a>(
642        &'a self,
643        call: &'a ToolCall,
644    ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>;
645
646    fn execute_tool_call_confirmed_erased<'a>(
647        &'a self,
648        call: &'a ToolCall,
649    ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
650    {
651        // TrustGateExecutor overrides ToolExecutor::execute_tool_call_confirmed; the blanket
652        // impl for T: ToolExecutor routes this call through it via execute_tool_call_confirmed_erased.
653        // Other implementors fall back to execute_tool_call_erased (normal enforcement path).
654        self.execute_tool_call_erased(call)
655    }
656
657    /// Inject environment variables for the currently active skill. No-op by default.
658    fn set_skill_env(&self, _env: Option<std::collections::HashMap<String, String>>) {}
659
660    /// Set the effective trust level for the currently active skill. No-op by default.
661    fn set_effective_trust(&self, _level: crate::SkillTrustLevel) {}
662
663    /// Whether the executor can safely retry this tool call on a transient error.
664    fn is_tool_retryable_erased(&self, tool_id: &str) -> bool;
665
666    /// Whether a tool call can be safely dispatched speculatively.
667    ///
668    /// Default: `false`. Override to `true` in read-only executors.
669    fn is_tool_speculatable_erased(&self, _tool_id: &str) -> bool {
670        false
671    }
672
673    /// Return `true` when `call` would require user confirmation before execution.
674    ///
675    /// This is a pure metadata/policy query — implementations must **not** execute the tool.
676    /// Used by the speculative engine to gate dispatch without causing double side-effects.
677    ///
678    /// Default: `false` (no confirmation required). Override in executors that enforce a
679    /// confirmation policy (e.g. `TrustGateExecutor`).
680    fn requires_confirmation_erased(&self, _call: &ToolCall) -> bool {
681        false
682    }
683}
684
685impl<T: ToolExecutor> ErasedToolExecutor for T {
686    fn execute_erased<'a>(
687        &'a self,
688        response: &'a str,
689    ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
690    {
691        Box::pin(self.execute(response))
692    }
693
694    fn execute_confirmed_erased<'a>(
695        &'a self,
696        response: &'a str,
697    ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
698    {
699        Box::pin(self.execute_confirmed(response))
700    }
701
702    fn tool_definitions_erased(&self) -> Vec<crate::registry::ToolDef> {
703        self.tool_definitions()
704    }
705
706    fn execute_tool_call_erased<'a>(
707        &'a self,
708        call: &'a ToolCall,
709    ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
710    {
711        Box::pin(self.execute_tool_call(call))
712    }
713
714    fn execute_tool_call_confirmed_erased<'a>(
715        &'a self,
716        call: &'a ToolCall,
717    ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
718    {
719        Box::pin(self.execute_tool_call_confirmed(call))
720    }
721
722    fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
723        ToolExecutor::set_skill_env(self, env);
724    }
725
726    fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
727        ToolExecutor::set_effective_trust(self, level);
728    }
729
730    fn is_tool_retryable_erased(&self, tool_id: &str) -> bool {
731        ToolExecutor::is_tool_retryable(self, tool_id)
732    }
733
734    fn is_tool_speculatable_erased(&self, tool_id: &str) -> bool {
735        ToolExecutor::is_tool_speculatable(self, tool_id)
736    }
737}
738
739/// Wraps `Arc<dyn ErasedToolExecutor>` so it can be used as a concrete `ToolExecutor`.
740///
741/// Enables dynamic composition of tool executors at runtime without static type chains.
742pub struct DynExecutor(pub std::sync::Arc<dyn ErasedToolExecutor>);
743
744impl ToolExecutor for DynExecutor {
745    fn execute(
746        &self,
747        response: &str,
748    ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
749        // Clone data to satisfy the 'static-ish bound: erased futures must not borrow self.
750        let inner = std::sync::Arc::clone(&self.0);
751        let response = response.to_owned();
752        async move { inner.execute_erased(&response).await }
753    }
754
755    fn execute_confirmed(
756        &self,
757        response: &str,
758    ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
759        let inner = std::sync::Arc::clone(&self.0);
760        let response = response.to_owned();
761        async move { inner.execute_confirmed_erased(&response).await }
762    }
763
764    fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
765        self.0.tool_definitions_erased()
766    }
767
768    fn execute_tool_call(
769        &self,
770        call: &ToolCall,
771    ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
772        let inner = std::sync::Arc::clone(&self.0);
773        let call = call.clone();
774        async move { inner.execute_tool_call_erased(&call).await }
775    }
776
777    fn execute_tool_call_confirmed(
778        &self,
779        call: &ToolCall,
780    ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
781        let inner = std::sync::Arc::clone(&self.0);
782        let call = call.clone();
783        async move { inner.execute_tool_call_confirmed_erased(&call).await }
784    }
785
786    fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
787        ErasedToolExecutor::set_skill_env(self.0.as_ref(), env);
788    }
789
790    fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
791        ErasedToolExecutor::set_effective_trust(self.0.as_ref(), level);
792    }
793
794    fn is_tool_retryable(&self, tool_id: &str) -> bool {
795        self.0.is_tool_retryable_erased(tool_id)
796    }
797
798    fn is_tool_speculatable(&self, tool_id: &str) -> bool {
799        self.0.is_tool_speculatable_erased(tool_id)
800    }
801}
802
803/// Extract fenced code blocks with the given language marker from text.
804///
805/// Searches for `` ```{lang} `` … `` ``` `` pairs, returning trimmed content.
806#[must_use]
807pub fn extract_fenced_blocks<'a>(text: &'a str, lang: &str) -> Vec<&'a str> {
808    let marker = format!("```{lang}");
809    let marker_len = marker.len();
810    let mut blocks = Vec::new();
811    let mut rest = text;
812
813    let mut search_from = 0;
814    while let Some(rel) = rest[search_from..].find(&marker) {
815        let start = search_from + rel;
816        let after = &rest[start + marker_len..];
817        // Word-boundary check: the character immediately after the marker must be
818        // whitespace, end-of-string, or a non-word character (not alphanumeric / _ / -).
819        // This prevents "```bash" from matching "```bashrc".
820        let boundary_ok = after
821            .chars()
822            .next()
823            .is_none_or(|c| !c.is_alphanumeric() && c != '_' && c != '-');
824        if !boundary_ok {
825            search_from = start + marker_len;
826            continue;
827        }
828        if let Some(end) = after.find("```") {
829            blocks.push(after[..end].trim());
830            rest = &after[end + 3..];
831            search_from = 0;
832        } else {
833            break;
834        }
835    }
836
837    blocks
838}
839
840#[cfg(test)]
841mod tests {
842    use super::*;
843
844    #[test]
845    fn tool_output_display() {
846        let output = ToolOutput {
847            tool_name: ToolName::new("bash"),
848            summary: "$ echo hello\nhello".to_owned(),
849            blocks_executed: 1,
850            filter_stats: None,
851            diff: None,
852            streamed: false,
853            terminal_id: None,
854            locations: None,
855            raw_response: None,
856            claim_source: None,
857        };
858        assert_eq!(output.to_string(), "$ echo hello\nhello");
859    }
860
861    #[test]
862    fn tool_error_blocked_display() {
863        let err = ToolError::Blocked {
864            command: "rm -rf /".to_owned(),
865        };
866        assert_eq!(err.to_string(), "command blocked by policy: rm -rf /");
867    }
868
869    #[test]
870    fn tool_error_sandbox_violation_display() {
871        let err = ToolError::SandboxViolation {
872            path: "/etc/shadow".to_owned(),
873        };
874        assert_eq!(err.to_string(), "path not allowed by sandbox: /etc/shadow");
875    }
876
877    #[test]
878    fn tool_error_confirmation_required_display() {
879        let err = ToolError::ConfirmationRequired {
880            command: "rm -rf /tmp".to_owned(),
881        };
882        assert_eq!(
883            err.to_string(),
884            "command requires confirmation: rm -rf /tmp"
885        );
886    }
887
888    #[test]
889    fn tool_error_timeout_display() {
890        let err = ToolError::Timeout { timeout_secs: 30 };
891        assert_eq!(err.to_string(), "command timed out after 30s");
892    }
893
894    #[test]
895    fn tool_error_invalid_params_display() {
896        let err = ToolError::InvalidParams {
897            message: "missing field `command`".to_owned(),
898        };
899        assert_eq!(
900            err.to_string(),
901            "invalid tool parameters: missing field `command`"
902        );
903    }
904
905    #[test]
906    fn deserialize_params_valid() {
907        #[derive(Debug, serde::Deserialize, PartialEq)]
908        struct P {
909            name: String,
910            count: u32,
911        }
912        let mut map = serde_json::Map::new();
913        map.insert("name".to_owned(), serde_json::json!("test"));
914        map.insert("count".to_owned(), serde_json::json!(42));
915        let p: P = deserialize_params(&map).unwrap();
916        assert_eq!(
917            p,
918            P {
919                name: "test".to_owned(),
920                count: 42
921            }
922        );
923    }
924
925    #[test]
926    fn deserialize_params_missing_required_field() {
927        #[derive(Debug, serde::Deserialize)]
928        #[allow(dead_code)]
929        struct P {
930            name: String,
931        }
932        let map = serde_json::Map::new();
933        let err = deserialize_params::<P>(&map).unwrap_err();
934        assert!(matches!(err, ToolError::InvalidParams { .. }));
935    }
936
937    #[test]
938    fn deserialize_params_wrong_type() {
939        #[derive(Debug, serde::Deserialize)]
940        #[allow(dead_code)]
941        struct P {
942            count: u32,
943        }
944        let mut map = serde_json::Map::new();
945        map.insert("count".to_owned(), serde_json::json!("not a number"));
946        let err = deserialize_params::<P>(&map).unwrap_err();
947        assert!(matches!(err, ToolError::InvalidParams { .. }));
948    }
949
950    #[test]
951    fn deserialize_params_all_optional_empty() {
952        #[derive(Debug, serde::Deserialize, PartialEq)]
953        struct P {
954            name: Option<String>,
955        }
956        let map = serde_json::Map::new();
957        let p: P = deserialize_params(&map).unwrap();
958        assert_eq!(p, P { name: None });
959    }
960
961    #[test]
962    fn deserialize_params_ignores_extra_fields() {
963        #[derive(Debug, serde::Deserialize, PartialEq)]
964        struct P {
965            name: String,
966        }
967        let mut map = serde_json::Map::new();
968        map.insert("name".to_owned(), serde_json::json!("test"));
969        map.insert("extra".to_owned(), serde_json::json!(true));
970        let p: P = deserialize_params(&map).unwrap();
971        assert_eq!(
972            p,
973            P {
974                name: "test".to_owned()
975            }
976        );
977    }
978
979    #[test]
980    fn tool_error_execution_display() {
981        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "bash not found");
982        let err = ToolError::Execution(io_err);
983        assert!(err.to_string().starts_with("execution failed:"));
984        assert!(err.to_string().contains("bash not found"));
985    }
986
987    // ErrorKind classification tests
988    #[test]
989    fn error_kind_timeout_is_transient() {
990        let err = ToolError::Timeout { timeout_secs: 30 };
991        assert_eq!(err.kind(), ErrorKind::Transient);
992    }
993
994    #[test]
995    fn error_kind_blocked_is_permanent() {
996        let err = ToolError::Blocked {
997            command: "rm -rf /".to_owned(),
998        };
999        assert_eq!(err.kind(), ErrorKind::Permanent);
1000    }
1001
1002    #[test]
1003    fn error_kind_sandbox_violation_is_permanent() {
1004        let err = ToolError::SandboxViolation {
1005            path: "/etc/shadow".to_owned(),
1006        };
1007        assert_eq!(err.kind(), ErrorKind::Permanent);
1008    }
1009
1010    #[test]
1011    fn error_kind_cancelled_is_permanent() {
1012        assert_eq!(ToolError::Cancelled.kind(), ErrorKind::Permanent);
1013    }
1014
1015    #[test]
1016    fn error_kind_invalid_params_is_permanent() {
1017        let err = ToolError::InvalidParams {
1018            message: "bad arg".to_owned(),
1019        };
1020        assert_eq!(err.kind(), ErrorKind::Permanent);
1021    }
1022
1023    #[test]
1024    fn error_kind_confirmation_required_is_permanent() {
1025        let err = ToolError::ConfirmationRequired {
1026            command: "rm /tmp/x".to_owned(),
1027        };
1028        assert_eq!(err.kind(), ErrorKind::Permanent);
1029    }
1030
1031    #[test]
1032    fn error_kind_execution_timed_out_is_transient() {
1033        let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "timeout");
1034        assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
1035    }
1036
1037    #[test]
1038    fn error_kind_execution_interrupted_is_transient() {
1039        let io_err = std::io::Error::new(std::io::ErrorKind::Interrupted, "interrupted");
1040        assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
1041    }
1042
1043    #[test]
1044    fn error_kind_execution_connection_reset_is_transient() {
1045        let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "reset");
1046        assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
1047    }
1048
1049    #[test]
1050    fn error_kind_execution_broken_pipe_is_transient() {
1051        let io_err = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pipe broken");
1052        assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
1053    }
1054
1055    #[test]
1056    fn error_kind_execution_would_block_is_transient() {
1057        let io_err = std::io::Error::new(std::io::ErrorKind::WouldBlock, "would block");
1058        assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
1059    }
1060
1061    #[test]
1062    fn error_kind_execution_connection_aborted_is_transient() {
1063        let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionAborted, "aborted");
1064        assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
1065    }
1066
1067    #[test]
1068    fn error_kind_execution_not_found_is_permanent() {
1069        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "not found");
1070        assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
1071    }
1072
1073    #[test]
1074    fn error_kind_execution_permission_denied_is_permanent() {
1075        let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
1076        assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
1077    }
1078
1079    #[test]
1080    fn error_kind_execution_other_is_permanent() {
1081        let io_err = std::io::Error::other("some other error");
1082        assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
1083    }
1084
1085    #[test]
1086    fn error_kind_execution_already_exists_is_permanent() {
1087        let io_err = std::io::Error::new(std::io::ErrorKind::AlreadyExists, "exists");
1088        assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
1089    }
1090
1091    #[test]
1092    fn error_kind_display() {
1093        assert_eq!(ErrorKind::Transient.to_string(), "transient");
1094        assert_eq!(ErrorKind::Permanent.to_string(), "permanent");
1095    }
1096
1097    #[test]
1098    fn truncate_tool_output_short_passthrough() {
1099        let short = "hello world";
1100        assert_eq!(truncate_tool_output(short), short);
1101    }
1102
1103    #[test]
1104    fn truncate_tool_output_exact_limit() {
1105        let exact = "a".repeat(MAX_TOOL_OUTPUT_CHARS);
1106        assert_eq!(truncate_tool_output(&exact), exact);
1107    }
1108
1109    #[test]
1110    fn truncate_tool_output_long_split() {
1111        let long = "x".repeat(MAX_TOOL_OUTPUT_CHARS + 1000);
1112        let result = truncate_tool_output(&long);
1113        assert!(result.contains("truncated"));
1114        assert!(result.len() < long.len());
1115    }
1116
1117    #[test]
1118    fn truncate_tool_output_notice_contains_count() {
1119        let long = "y".repeat(MAX_TOOL_OUTPUT_CHARS + 2000);
1120        let result = truncate_tool_output(&long);
1121        assert!(result.contains("truncated"));
1122        assert!(result.contains("chars"));
1123    }
1124
1125    #[derive(Debug)]
1126    struct DefaultExecutor;
1127    impl ToolExecutor for DefaultExecutor {
1128        async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
1129            Ok(None)
1130        }
1131    }
1132
1133    #[tokio::test]
1134    async fn execute_tool_call_default_returns_none() {
1135        let exec = DefaultExecutor;
1136        let call = ToolCall {
1137            tool_id: ToolName::new("anything"),
1138            params: serde_json::Map::new(),
1139            caller_id: None,
1140        };
1141        let result = exec.execute_tool_call(&call).await.unwrap();
1142        assert!(result.is_none());
1143    }
1144
1145    #[test]
1146    fn filter_stats_savings_pct() {
1147        let fs = FilterStats {
1148            raw_chars: 1000,
1149            filtered_chars: 200,
1150            ..Default::default()
1151        };
1152        assert!((fs.savings_pct() - 80.0).abs() < 0.01);
1153    }
1154
1155    #[test]
1156    fn filter_stats_savings_pct_zero() {
1157        let fs = FilterStats::default();
1158        assert!((fs.savings_pct()).abs() < 0.01);
1159    }
1160
1161    #[test]
1162    fn filter_stats_estimated_tokens_saved() {
1163        let fs = FilterStats {
1164            raw_chars: 1000,
1165            filtered_chars: 200,
1166            ..Default::default()
1167        };
1168        assert_eq!(fs.estimated_tokens_saved(), 200); // (1000 - 200) / 4
1169    }
1170
1171    #[test]
1172    fn filter_stats_format_inline() {
1173        let fs = FilterStats {
1174            raw_chars: 1000,
1175            filtered_chars: 200,
1176            raw_lines: 342,
1177            filtered_lines: 28,
1178            ..Default::default()
1179        };
1180        let line = fs.format_inline("shell");
1181        assert_eq!(line, "[shell] 342 lines \u{2192} 28 lines, 80.0% filtered");
1182    }
1183
1184    #[test]
1185    fn filter_stats_format_inline_zero() {
1186        let fs = FilterStats::default();
1187        let line = fs.format_inline("bash");
1188        assert_eq!(line, "[bash] 0 lines \u{2192} 0 lines, 0.0% filtered");
1189    }
1190
1191    // DynExecutor tests
1192
1193    struct FixedExecutor {
1194        tool_id: &'static str,
1195        output: &'static str,
1196    }
1197
1198    impl ToolExecutor for FixedExecutor {
1199        async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
1200            Ok(Some(ToolOutput {
1201                tool_name: ToolName::new(self.tool_id),
1202                summary: self.output.to_owned(),
1203                blocks_executed: 1,
1204                filter_stats: None,
1205                diff: None,
1206                streamed: false,
1207                terminal_id: None,
1208                locations: None,
1209                raw_response: None,
1210                claim_source: None,
1211            }))
1212        }
1213
1214        fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
1215            vec![]
1216        }
1217
1218        async fn execute_tool_call(
1219            &self,
1220            _call: &ToolCall,
1221        ) -> Result<Option<ToolOutput>, ToolError> {
1222            Ok(Some(ToolOutput {
1223                tool_name: ToolName::new(self.tool_id),
1224                summary: self.output.to_owned(),
1225                blocks_executed: 1,
1226                filter_stats: None,
1227                diff: None,
1228                streamed: false,
1229                terminal_id: None,
1230                locations: None,
1231                raw_response: None,
1232                claim_source: None,
1233            }))
1234        }
1235    }
1236
1237    #[tokio::test]
1238    async fn dyn_executor_execute_delegates() {
1239        let inner = std::sync::Arc::new(FixedExecutor {
1240            tool_id: "bash",
1241            output: "hello",
1242        });
1243        let exec = DynExecutor(inner);
1244        let result = exec.execute("```bash\necho hello\n```").await.unwrap();
1245        assert!(result.is_some());
1246        assert_eq!(result.unwrap().summary, "hello");
1247    }
1248
1249    #[tokio::test]
1250    async fn dyn_executor_execute_confirmed_delegates() {
1251        let inner = std::sync::Arc::new(FixedExecutor {
1252            tool_id: "bash",
1253            output: "confirmed",
1254        });
1255        let exec = DynExecutor(inner);
1256        let result = exec.execute_confirmed("...").await.unwrap();
1257        assert!(result.is_some());
1258        assert_eq!(result.unwrap().summary, "confirmed");
1259    }
1260
1261    #[test]
1262    fn dyn_executor_tool_definitions_delegates() {
1263        let inner = std::sync::Arc::new(FixedExecutor {
1264            tool_id: "my_tool",
1265            output: "",
1266        });
1267        let exec = DynExecutor(inner);
1268        // FixedExecutor returns empty definitions; verify delegation occurs without panic.
1269        let defs = exec.tool_definitions();
1270        assert!(defs.is_empty());
1271    }
1272
1273    #[tokio::test]
1274    async fn dyn_executor_execute_tool_call_delegates() {
1275        let inner = std::sync::Arc::new(FixedExecutor {
1276            tool_id: "bash",
1277            output: "tool_call_result",
1278        });
1279        let exec = DynExecutor(inner);
1280        let call = ToolCall {
1281            tool_id: ToolName::new("bash"),
1282            params: serde_json::Map::new(),
1283            caller_id: None,
1284        };
1285        let result = exec.execute_tool_call(&call).await.unwrap();
1286        assert!(result.is_some());
1287        assert_eq!(result.unwrap().summary, "tool_call_result");
1288    }
1289
1290    #[test]
1291    fn dyn_executor_set_effective_trust_delegates() {
1292        use std::sync::atomic::{AtomicU8, Ordering};
1293
1294        struct TrustCapture(AtomicU8);
1295        impl ToolExecutor for TrustCapture {
1296            async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
1297                Ok(None)
1298            }
1299            fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
1300                // encode: Trusted=0, Verified=1, Quarantined=2, Blocked=3
1301                let v = match level {
1302                    crate::SkillTrustLevel::Trusted => 0u8,
1303                    crate::SkillTrustLevel::Verified => 1,
1304                    crate::SkillTrustLevel::Quarantined => 2,
1305                    crate::SkillTrustLevel::Blocked => 3,
1306                };
1307                self.0.store(v, Ordering::Relaxed);
1308            }
1309        }
1310
1311        let inner = std::sync::Arc::new(TrustCapture(AtomicU8::new(0)));
1312        let exec =
1313            DynExecutor(std::sync::Arc::clone(&inner) as std::sync::Arc<dyn ErasedToolExecutor>);
1314        ToolExecutor::set_effective_trust(&exec, crate::SkillTrustLevel::Quarantined);
1315        assert_eq!(inner.0.load(Ordering::Relaxed), 2);
1316
1317        ToolExecutor::set_effective_trust(&exec, crate::SkillTrustLevel::Blocked);
1318        assert_eq!(inner.0.load(Ordering::Relaxed), 3);
1319    }
1320
1321    #[test]
1322    fn extract_fenced_blocks_no_prefix_match() {
1323        // ```bashrc must NOT match when searching for "bash"
1324        assert!(extract_fenced_blocks("```bashrc\nfoo\n```", "bash").is_empty());
1325        // exact match
1326        assert_eq!(
1327            extract_fenced_blocks("```bash\nfoo\n```", "bash"),
1328            vec!["foo"]
1329        );
1330        // trailing space is fine
1331        assert_eq!(
1332            extract_fenced_blocks("```bash \nfoo\n```", "bash"),
1333            vec!["foo"]
1334        );
1335    }
1336
1337    // ── ToolError::category() delegation tests ────────────────────────────────
1338
1339    #[test]
1340    fn tool_error_http_400_category_is_invalid_parameters() {
1341        use crate::error_taxonomy::ToolErrorCategory;
1342        let err = ToolError::Http {
1343            status: 400,
1344            message: "bad request".to_owned(),
1345        };
1346        assert_eq!(err.category(), ToolErrorCategory::InvalidParameters);
1347    }
1348
1349    #[test]
1350    fn tool_error_http_401_category_is_policy_blocked() {
1351        use crate::error_taxonomy::ToolErrorCategory;
1352        let err = ToolError::Http {
1353            status: 401,
1354            message: "unauthorized".to_owned(),
1355        };
1356        assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1357    }
1358
1359    #[test]
1360    fn tool_error_http_403_category_is_policy_blocked() {
1361        use crate::error_taxonomy::ToolErrorCategory;
1362        let err = ToolError::Http {
1363            status: 403,
1364            message: "forbidden".to_owned(),
1365        };
1366        assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1367    }
1368
1369    #[test]
1370    fn tool_error_http_404_category_is_permanent_failure() {
1371        use crate::error_taxonomy::ToolErrorCategory;
1372        let err = ToolError::Http {
1373            status: 404,
1374            message: "not found".to_owned(),
1375        };
1376        assert_eq!(err.category(), ToolErrorCategory::PermanentFailure);
1377    }
1378
1379    #[test]
1380    fn tool_error_http_429_category_is_rate_limited() {
1381        use crate::error_taxonomy::ToolErrorCategory;
1382        let err = ToolError::Http {
1383            status: 429,
1384            message: "too many requests".to_owned(),
1385        };
1386        assert_eq!(err.category(), ToolErrorCategory::RateLimited);
1387    }
1388
1389    #[test]
1390    fn tool_error_http_500_category_is_server_error() {
1391        use crate::error_taxonomy::ToolErrorCategory;
1392        let err = ToolError::Http {
1393            status: 500,
1394            message: "internal server error".to_owned(),
1395        };
1396        assert_eq!(err.category(), ToolErrorCategory::ServerError);
1397    }
1398
1399    #[test]
1400    fn tool_error_http_502_category_is_server_error() {
1401        use crate::error_taxonomy::ToolErrorCategory;
1402        let err = ToolError::Http {
1403            status: 502,
1404            message: "bad gateway".to_owned(),
1405        };
1406        assert_eq!(err.category(), ToolErrorCategory::ServerError);
1407    }
1408
1409    #[test]
1410    fn tool_error_http_503_category_is_server_error() {
1411        use crate::error_taxonomy::ToolErrorCategory;
1412        let err = ToolError::Http {
1413            status: 503,
1414            message: "service unavailable".to_owned(),
1415        };
1416        assert_eq!(err.category(), ToolErrorCategory::ServerError);
1417    }
1418
1419    #[test]
1420    fn tool_error_http_503_is_transient_triggers_phase2_retry() {
1421        // Phase 2 retry fires when err.kind() == ErrorKind::Transient.
1422        // Verify the full chain: Http{503} -> ServerError -> is_retryable() -> Transient.
1423        let err = ToolError::Http {
1424            status: 503,
1425            message: "service unavailable".to_owned(),
1426        };
1427        assert_eq!(
1428            err.kind(),
1429            ErrorKind::Transient,
1430            "HTTP 503 must be Transient so Phase 2 retry fires"
1431        );
1432    }
1433
1434    #[test]
1435    fn tool_error_blocked_category_is_policy_blocked() {
1436        use crate::error_taxonomy::ToolErrorCategory;
1437        let err = ToolError::Blocked {
1438            command: "rm -rf /".to_owned(),
1439        };
1440        assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1441    }
1442
1443    #[test]
1444    fn tool_error_sandbox_violation_category_is_policy_blocked() {
1445        use crate::error_taxonomy::ToolErrorCategory;
1446        let err = ToolError::SandboxViolation {
1447            path: "/etc/shadow".to_owned(),
1448        };
1449        assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1450    }
1451
1452    #[test]
1453    fn tool_error_confirmation_required_category() {
1454        use crate::error_taxonomy::ToolErrorCategory;
1455        let err = ToolError::ConfirmationRequired {
1456            command: "rm /tmp/x".to_owned(),
1457        };
1458        assert_eq!(err.category(), ToolErrorCategory::ConfirmationRequired);
1459    }
1460
1461    #[test]
1462    fn tool_error_timeout_category() {
1463        use crate::error_taxonomy::ToolErrorCategory;
1464        let err = ToolError::Timeout { timeout_secs: 30 };
1465        assert_eq!(err.category(), ToolErrorCategory::Timeout);
1466    }
1467
1468    #[test]
1469    fn tool_error_cancelled_category() {
1470        use crate::error_taxonomy::ToolErrorCategory;
1471        assert_eq!(
1472            ToolError::Cancelled.category(),
1473            ToolErrorCategory::Cancelled
1474        );
1475    }
1476
1477    #[test]
1478    fn tool_error_invalid_params_category() {
1479        use crate::error_taxonomy::ToolErrorCategory;
1480        let err = ToolError::InvalidParams {
1481            message: "missing field".to_owned(),
1482        };
1483        assert_eq!(err.category(), ToolErrorCategory::InvalidParameters);
1484    }
1485
1486    // B2 regression: Execution(NotFound) must NOT produce ToolNotFound.
1487    #[test]
1488    fn tool_error_execution_not_found_category_is_permanent_failure() {
1489        use crate::error_taxonomy::ToolErrorCategory;
1490        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "bash: not found");
1491        let err = ToolError::Execution(io_err);
1492        let cat = err.category();
1493        assert_ne!(
1494            cat,
1495            ToolErrorCategory::ToolNotFound,
1496            "Execution(NotFound) must NOT map to ToolNotFound"
1497        );
1498        assert_eq!(cat, ToolErrorCategory::PermanentFailure);
1499    }
1500
1501    #[test]
1502    fn tool_error_execution_timed_out_category_is_timeout() {
1503        use crate::error_taxonomy::ToolErrorCategory;
1504        let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "timed out");
1505        assert_eq!(
1506            ToolError::Execution(io_err).category(),
1507            ToolErrorCategory::Timeout
1508        );
1509    }
1510
1511    #[test]
1512    fn tool_error_execution_connection_refused_category_is_network_error() {
1513        use crate::error_taxonomy::ToolErrorCategory;
1514        let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
1515        assert_eq!(
1516            ToolError::Execution(io_err).category(),
1517            ToolErrorCategory::NetworkError
1518        );
1519    }
1520
1521    // B4 regression: Http/network/transient categories must NOT be quality failures.
1522    #[test]
1523    fn b4_tool_error_http_429_not_quality_failure() {
1524        let err = ToolError::Http {
1525            status: 429,
1526            message: "rate limited".to_owned(),
1527        };
1528        assert!(
1529            !err.category().is_quality_failure(),
1530            "RateLimited must not be a quality failure"
1531        );
1532    }
1533
1534    #[test]
1535    fn b4_tool_error_http_503_not_quality_failure() {
1536        let err = ToolError::Http {
1537            status: 503,
1538            message: "service unavailable".to_owned(),
1539        };
1540        assert!(
1541            !err.category().is_quality_failure(),
1542            "ServerError must not be a quality failure"
1543        );
1544    }
1545
1546    #[test]
1547    fn b4_tool_error_execution_timed_out_not_quality_failure() {
1548        let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "timeout");
1549        assert!(
1550            !ToolError::Execution(io_err).category().is_quality_failure(),
1551            "Timeout must not be a quality failure"
1552        );
1553    }
1554
1555    // ── ToolError::Shell category tests ──────────────────────────────────────
1556
1557    #[test]
1558    fn tool_error_shell_exit126_is_policy_blocked() {
1559        use crate::error_taxonomy::ToolErrorCategory;
1560        let err = ToolError::Shell {
1561            exit_code: 126,
1562            category: ToolErrorCategory::PolicyBlocked,
1563            message: "permission denied".to_owned(),
1564        };
1565        assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1566    }
1567
1568    #[test]
1569    fn tool_error_shell_exit127_is_permanent_failure() {
1570        use crate::error_taxonomy::ToolErrorCategory;
1571        let err = ToolError::Shell {
1572            exit_code: 127,
1573            category: ToolErrorCategory::PermanentFailure,
1574            message: "command not found".to_owned(),
1575        };
1576        assert_eq!(err.category(), ToolErrorCategory::PermanentFailure);
1577        assert!(!err.category().is_retryable());
1578    }
1579
1580    #[test]
1581    fn tool_error_shell_not_quality_failure() {
1582        use crate::error_taxonomy::ToolErrorCategory;
1583        let err = ToolError::Shell {
1584            exit_code: 127,
1585            category: ToolErrorCategory::PermanentFailure,
1586            message: "command not found".to_owned(),
1587        };
1588        // Shell exit errors are not attributable to LLM output quality.
1589        assert!(!err.category().is_quality_failure());
1590    }
1591}