Skip to main content

zeph_tools/
executor.rs

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