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