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