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