Skip to main content

rig_core/tool/
result.rs

1//! Canonical structured tool errors and execution results.
2
3use std::{error::Error, sync::Arc};
4
5use crate::{
6    tool::ToolOutput,
7    wasm_compat::{WasmCompatSend, WasmCompatSync},
8};
9
10/// Normalized classification for a tool execution error.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12pub enum ToolErrorKind {
13    /// Arguments could not be decoded or validated.
14    InvalidArgs,
15    /// Execution exceeded its deadline.
16    Timeout,
17    /// Execution was cancelled.
18    Cancelled,
19    /// The requested tool or resource was not found.
20    NotFound,
21    /// An authorization or permission check failed. Intentional tool refusals
22    /// use this normalized kind with a separate refusal disposition.
23    PermissionDenied,
24    /// A rate limit was reached.
25    RateLimited,
26    /// An upstream provider failed.
27    Provider,
28    /// A network operation failed.
29    Network,
30    /// Any other failure.
31    Other,
32}
33
34// One row per kind: (stable name, default retryability, default model
35// feedback). The macro emits the three parallel accessors from the single
36// table so a new kind cannot update one and miss another.
37macro_rules! kind_defaults {
38    ($($variant:ident => ($name:literal, $retryable:expr, $feedback:literal)),+ $(,)?) => {
39        impl ToolErrorKind {
40            /// Stable machine-readable name.
41            pub const fn as_str(self) -> &'static str {
42                match self { $(Self::$variant => $name,)+ }
43            }
44
45            const fn default_retryable(self) -> Option<bool> {
46                match self { $(Self::$variant => $retryable,)+ }
47            }
48
49            const fn default_model_feedback(self) -> &'static str {
50                match self { $(Self::$variant => $feedback,)+ }
51            }
52        }
53    };
54}
55
56kind_defaults! {
57    InvalidArgs => ("invalid_args", Some(false), "tool arguments were invalid"),
58    Timeout => ("timeout", Some(true), "tool execution timed out"),
59    Cancelled => ("cancelled", Some(false), "tool execution was cancelled"),
60    NotFound => ("not_found", Some(false), "the requested tool or resource was not found"),
61    PermissionDenied => ("permission_denied", Some(false), "the tool denied the request"),
62    RateLimited => ("rate_limited", Some(true), "the tool was rate limited; try again later"),
63    Provider => ("provider", None, "the tool provider failed"),
64    Network => ("network", Some(true), "the tool could not reach its upstream service"),
65    Other => ("other", None, "the tool failed"),
66}
67
68// One `ToolExecutionError` constructor per kind, from a single table so a new
69// kind cannot miss its shorthand. `refused` stays hand-written because it also
70// sets the refusal disposition.
71macro_rules! kind_ctors {
72    ($($(#[$doc:meta])* $ctor:ident => $variant:ident),+ $(,)?) => {
73        impl ToolExecutionError {
74            $($(#[$doc])*
75            pub fn $ctor(message: impl Into<String>) -> Self {
76                Self::new(ToolErrorKind::$variant, message)
77            })+
78        }
79    };
80}
81
82kind_ctors! {
83    /// Invalid arguments.
84    invalid_args => InvalidArgs,
85    /// Timeout.
86    timeout => Timeout,
87    /// Cancellation.
88    cancelled => Cancelled,
89    /// Missing tool or resource.
90    not_found => NotFound,
91    /// An authorization or permission failure.
92    ///
93    /// This is an ordinary execution error. Use [`Self::refused`] when the tool
94    /// intentionally declines the operation so hooks and telemetry can preserve
95    /// the refusal as a distinct disposition.
96    permission_denied => PermissionDenied,
97    /// Rate limit.
98    rate_limited => RateLimited,
99    /// Upstream provider failure.
100    provider => Provider,
101    /// Network failure.
102    network => Network,
103    /// Catch-all failure.
104    other => Other,
105}
106
107impl std::fmt::Display for ToolErrorKind {
108    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109        f.write_str(self.as_str())
110    }
111}
112
113/// One public envelope for every tool execution failure.
114///
115/// It carries normalized policy fields, separate operator-facing diagnostics
116/// and model-visible output, and an optional concrete source that can be
117/// downcast. Explicit constructors use the diagnostic message as model-visible
118/// output so deliberately authored validation failures remain actionable.
119/// [`Self::from_error`] instead treats an arbitrary source as operator-only and
120/// exposes safe kind-level feedback. Use [`Self::with_model_feedback`] or
121/// [`Self::with_model_output`] to provide a purpose-built presentation.
122#[derive(Clone)]
123pub struct ToolExecutionError {
124    kind: ToolErrorKind,
125    message: String,
126    model_output: Box<ToolOutput>,
127    retryable: Option<bool>,
128    code: Option<String>,
129    http_status: Option<u16>,
130    refusal: bool,
131    #[cfg(not(target_family = "wasm"))]
132    source: Option<Arc<dyn Error + Send + Sync + 'static>>,
133    #[cfg(target_family = "wasm")]
134    source: Option<Arc<dyn Error + 'static>>,
135}
136
137impl ToolExecutionError {
138    /// Construct an error with an explicit normalized kind.
139    pub fn new(kind: ToolErrorKind, message: impl Into<String>) -> Self {
140        let message = message.into();
141        Self {
142            kind,
143            model_output: Box::new(ToolOutput::text(message.clone())),
144            message,
145            retryable: kind.default_retryable(),
146            code: None,
147            http_status: None,
148            refusal: false,
149            source: None,
150        }
151    }
152
153    /// An intentional, tool-authored refusal.
154    ///
155    /// Refusals use the normalized [`ToolErrorKind::PermissionDenied`] kind but
156    /// remain distinct from permission failures in [`ToolResult`].
157    pub fn refused(message: impl Into<String>) -> Self {
158        let mut error = Self::new(ToolErrorKind::PermissionDenied, message);
159        error.refusal = true;
160        error
161    }
162
163    /// Build a safely presented `Other` error from a concrete source.
164    ///
165    /// The source's display string remains available as the operator-facing
166    /// [`Self::message`] and the source remains downcastable, but the model sees
167    /// only the stable feedback for [`ToolErrorKind::Other`]. Passing an existing
168    /// `ToolExecutionError` preserves its classification and presentation.
169    pub fn from_error<E>(error: E) -> Self
170    where
171        E: Error + WasmCompatSend + WasmCompatSync + 'static,
172    {
173        #[cfg(not(target_family = "wasm"))]
174        {
175            let source: Box<dyn Error + Send + Sync + 'static> = Box::new(error);
176            return match source.downcast::<Self>() {
177                Ok(error) => *error,
178                Err(source) => {
179                    let message = source.to_string();
180                    let mut error = Self::other(message).redact_model_feedback();
181                    error.source = Some(Arc::from(source));
182                    error
183                }
184            };
185        }
186        #[cfg(target_family = "wasm")]
187        {
188            let source: Box<dyn Error + 'static> = Box::new(error);
189            match source.downcast::<Self>() {
190                Ok(error) => *error,
191                Err(source) => {
192                    let message = source.to_string();
193                    let mut error = Self::other(message).redact_model_feedback();
194                    error.source = Some(Arc::from(source));
195                    error
196                }
197            }
198        }
199    }
200
201    /// Replace the model-visible output with literal text feedback.
202    pub fn with_model_feedback(mut self, feedback: impl Into<String>) -> Self {
203        self.model_output = Box::new(ToolOutput::text(feedback));
204        self
205    }
206
207    /// Replace the model-visible output with canonical JSON or multimodal
208    /// content.
209    pub fn with_model_output(mut self, output: ToolOutput) -> Self {
210        self.model_output = Box::new(output);
211        self
212    }
213
214    /// Replace potentially sensitive diagnostics with stable, kind-specific
215    /// model feedback.
216    ///
217    /// Explicit error constructors make messages model-visible by default to
218    /// keep failures actionable. Call this when an explicitly constructed
219    /// error's operator diagnostic may contain secrets; [`Self::from_error`]
220    /// already uses this safe presentation for arbitrary source errors.
221    pub fn redact_model_feedback(mut self) -> Self {
222        self.model_output = Box::new(ToolOutput::text(self.kind.default_model_feedback()));
223        self
224    }
225
226    /// Override the retryability hint.
227    pub fn with_retryable(mut self, retryable: bool) -> Self {
228        self.retryable = Some(retryable);
229        self
230    }
231
232    /// Attach an application/provider code.
233    pub fn with_code(mut self, code: impl Into<String>) -> Self {
234        self.code = Some(code.into());
235        self
236    }
237
238    /// Attach an HTTP status.
239    pub fn with_http_status(mut self, status: u16) -> Self {
240        self.http_status = Some(status);
241        self
242    }
243
244    /// Preserve a concrete source for later downcasting.
245    pub fn with_source<E>(mut self, source: E) -> Self
246    where
247        E: Error + WasmCompatSend + WasmCompatSync + 'static,
248    {
249        self.source = Some(Arc::new(source));
250        self
251    }
252
253    /// Normalized kind.
254    pub const fn kind(&self) -> ToolErrorKind {
255        self.kind
256    }
257
258    /// Operator-facing message.
259    pub fn message(&self) -> &str {
260        &self.message
261    }
262
263    /// Literal model feedback, when the presentation is exactly one plain text
264    /// block.
265    ///
266    /// Use [`Self::model_output`] for JSON or multimodal feedback.
267    pub fn model_feedback(&self) -> Option<&str> {
268        self.model_output.as_text()
269    }
270
271    /// Canonical model-visible presentation for this error.
272    pub fn model_output(&self) -> &ToolOutput {
273        &self.model_output
274    }
275
276    /// Retryability hint.
277    pub const fn retryable(&self) -> Option<bool> {
278        self.retryable
279    }
280
281    /// Application/provider code.
282    pub fn code(&self) -> Option<&str> {
283        self.code.as_deref()
284    }
285
286    /// HTTP status.
287    pub const fn http_status(&self) -> Option<u16> {
288        self.http_status
289    }
290
291    /// Whether the tool intentionally refused the operation.
292    pub const fn is_refusal(&self) -> bool {
293        self.refusal
294    }
295
296    /// Downcast the concrete source to `E`.
297    pub fn downcast_ref<E>(&self) -> Option<&E>
298    where
299        E: Error + WasmCompatSend + WasmCompatSync + 'static,
300    {
301        self.source.as_ref()?.downcast_ref::<E>()
302    }
303
304    /// Whether the concrete source has type `E`.
305    pub fn is<E>(&self) -> bool
306    where
307        E: Error + WasmCompatSend + WasmCompatSync + 'static,
308    {
309        self.downcast_ref::<E>().is_some()
310    }
311}
312
313impl std::fmt::Display for ToolExecutionError {
314    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
315        f.write_str(&self.message)
316    }
317}
318
319impl std::fmt::Debug for ToolExecutionError {
320    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
321        f.debug_struct("ToolExecutionError")
322            .field("kind", &self.kind)
323            .field("retryable", &self.retryable)
324            .field("code", &self.code)
325            .field("http_status", &self.http_status)
326            .field("refusal", &self.refusal)
327            .field("model_output", &"<redacted>")
328            .field("source_configured", &self.source.is_some())
329            .finish()
330    }
331}
332
333impl Error for ToolExecutionError {
334    fn source(&self) -> Option<&(dyn Error + 'static)> {
335        self.source
336            .as_deref()
337            .map(|source| source as &(dyn Error + 'static))
338    }
339}
340
341/// Private mutually exclusive state behind [`ToolResult`].
342#[derive(Clone)]
343enum ToolDisposition {
344    Success(ToolOutput),
345    Error(ToolExecutionError),
346    Refused(ToolExecutionError),
347    Skipped(ToolOutput),
348}
349
350/// The single structured execution view used by dispatch, hooks, and telemetry.
351///
352/// Each result has exactly one disposition. The tagged state is private so tool
353/// authors keep returning ordinary `Result` values while runtime callers use
354/// the stable query methods on this type.
355#[derive(Clone)]
356pub struct ToolResult {
357    disposition: ToolDisposition,
358}
359
360impl std::fmt::Debug for ToolResult {
361    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
362        let error = match &self.disposition {
363            ToolDisposition::Error(error) | ToolDisposition::Refused(error) => Some(error),
364            ToolDisposition::Success(_) | ToolDisposition::Skipped(_) => None,
365        };
366        formatter
367            .debug_struct("ToolResult")
368            .field("status", &self.status_name())
369            .field("error_kind", &error.map(ToolExecutionError::kind))
370            .field("retryable", &error.and_then(ToolExecutionError::retryable))
371            .field("code", &error.and_then(ToolExecutionError::code))
372            .field(
373                "http_status",
374                &error.and_then(ToolExecutionError::http_status),
375            )
376            .finish()
377    }
378}
379
380impl ToolResult {
381    /// Creates a successful canonical tool result.
382    pub fn success(output: ToolOutput) -> Self {
383        Self {
384            disposition: ToolDisposition::Success(output),
385        }
386    }
387
388    /// Creates a failed or refused canonical tool result.
389    pub fn failed(error: ToolExecutionError) -> Self {
390        let disposition = if error.is_refusal() {
391            ToolDisposition::Refused(error)
392        } else {
393            ToolDisposition::Error(error)
394        };
395        Self { disposition }
396    }
397
398    /// Creates a result for a call skipped by runtime policy.
399    pub fn skipped(reason: impl Into<String>) -> Self {
400        Self {
401            disposition: ToolDisposition::Skipped(ToolOutput::text(reason)),
402        }
403    }
404
405    /// Canonical model-visible output before any presentation-only hook rewrite.
406    pub fn output(&self) -> &ToolOutput {
407        match &self.disposition {
408            ToolDisposition::Success(output) | ToolDisposition::Skipped(output) => output,
409            ToolDisposition::Error(error) | ToolDisposition::Refused(error) => error.model_output(),
410        }
411    }
412
413    /// Structured execution error, if execution failed.
414    ///
415    /// Intentional refusals are available through [`Self::refusal`] instead.
416    pub fn error(&self) -> Option<&ToolExecutionError> {
417        match &self.disposition {
418            ToolDisposition::Error(error) => Some(error),
419            ToolDisposition::Success(_)
420            | ToolDisposition::Refused(_)
421            | ToolDisposition::Skipped(_) => None,
422        }
423    }
424
425    /// Structured refusal details, if the tool intentionally declined the call.
426    ///
427    /// This is mutually exclusive with [`Self::error`].
428    pub fn refusal(&self) -> Option<&ToolExecutionError> {
429        match &self.disposition {
430            ToolDisposition::Refused(error) => Some(error),
431            ToolDisposition::Success(_)
432            | ToolDisposition::Error(_)
433            | ToolDisposition::Skipped(_) => None,
434        }
435    }
436
437    /// Whether the tool completed successfully.
438    pub fn is_success(&self) -> bool {
439        matches!(&self.disposition, ToolDisposition::Success(_))
440    }
441
442    /// Whether execution failed.
443    ///
444    /// An intentional refusal is not an execution error; inspect
445    /// [`Self::is_refused`] instead.
446    pub fn is_error(&self) -> bool {
447        matches!(&self.disposition, ToolDisposition::Error(_))
448    }
449
450    /// Whether the framework skipped execution before the tool body ran.
451    pub fn is_skipped(&self) -> bool {
452        matches!(&self.disposition, ToolDisposition::Skipped(_))
453    }
454
455    /// Whether a tool refused execution.
456    pub fn is_refused(&self) -> bool {
457        matches!(&self.disposition, ToolDisposition::Refused(_))
458    }
459
460    /// Whether this is an error of exactly `kind`.
461    ///
462    /// A refusal does not match, even though its envelope uses the normalized
463    /// [`ToolErrorKind::PermissionDenied`] kind.
464    pub fn is_error_kind(&self, kind: ToolErrorKind) -> bool {
465        self.error().is_some_and(|error| error.kind == kind)
466    }
467
468    /// Returns the stable telemetry name for this result disposition.
469    pub fn status_name(&self) -> &'static str {
470        match &self.disposition {
471            ToolDisposition::Success(_) => "success",
472            ToolDisposition::Error(_) => "error",
473            ToolDisposition::Refused(_) => "denied",
474            ToolDisposition::Skipped(_) => "skipped",
475        }
476    }
477}
478
479#[cfg(not(target_family = "wasm"))]
480const _: fn() = || {
481    fn assert_send_sync<T: Send + Sync>() {}
482    assert_send_sync::<ToolExecutionError>();
483    assert_send_sync::<ToolResult>();
484};
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489
490    #[derive(Debug, thiserror::Error)]
491    #[error("secret detail")]
492    struct Concrete;
493
494    #[test]
495    fn envelope_is_classified_cloneable_downcastable_and_redacted() {
496        let error = ToolExecutionError::provider("operator message")
497            .with_model_feedback("safe feedback")
498            .with_http_status(503)
499            .with_source(Concrete);
500        let cloned = error.clone();
501        assert_eq!(error.kind(), ToolErrorKind::Provider);
502        assert_eq!(error.model_feedback(), Some("safe feedback"));
503        assert_eq!(error.http_status(), Some(503));
504        assert!(cloned.is::<Concrete>());
505        assert!(!format!("{error:?}").contains("secret detail"));
506    }
507
508    #[test]
509    fn converting_an_existing_envelope_preserves_classification() {
510        let error = ToolExecutionError::from_error(ToolExecutionError::timeout("slow"));
511        assert_eq!(error.kind(), ToolErrorKind::Timeout);
512        assert_eq!(error.retryable(), Some(true));
513    }
514
515    #[test]
516    fn detailed_diagnostics_are_model_visible_by_default() {
517        let error = ToolExecutionError::provider("upstream rejected field `region`");
518        let result = ToolResult::failed(error.clone());
519
520        assert_eq!(error.message(), "upstream rejected field `region`");
521        assert_eq!(
522            error.model_feedback(),
523            Some("upstream rejected field `region`")
524        );
525        assert_eq!(
526            result.output().as_text(),
527            Some("upstream rejected field `region`")
528        );
529    }
530
531    #[test]
532    fn sensitive_diagnostics_can_be_explicitly_redacted() {
533        let error = ToolExecutionError::provider("authorization header Bearer secret-token")
534            .redact_model_feedback();
535        let result = ToolResult::failed(error.clone());
536
537        assert_eq!(error.message(), "authorization header Bearer secret-token");
538        assert_eq!(error.model_feedback(), Some("the tool provider failed"));
539        assert_eq!(result.output().as_text(), Some("the tool provider failed"));
540        assert!(!result.output().render().contains("secret-token"));
541    }
542
543    #[test]
544    fn errors_can_expose_structured_model_output() {
545        let output = ToolOutput::json(serde_json::json!({
546            "error": "invalid region",
547            "allowed": ["us", "eu"]
548        }));
549        let result = ToolResult::failed(
550            ToolExecutionError::invalid_args("region was invalid")
551                .with_model_output(output.clone()),
552        );
553
554        assert_eq!(result.output(), &output);
555        assert_eq!(result.error().unwrap().model_output(), &output);
556        assert_eq!(result.error().unwrap().model_feedback(), None);
557    }
558
559    #[test]
560    fn skip_refusal_and_permission_failure_are_distinct() {
561        let skipped = ToolResult::skipped("policy");
562        let refused = ToolResult::failed(ToolExecutionError::refused("tool refused"));
563        let permission_failure = ToolResult::failed(ToolExecutionError::permission_denied(
564            "authorization failed",
565        ));
566        assert!(skipped.is_skipped());
567        assert!(!skipped.is_refused());
568        assert!(refused.is_refused());
569        assert!(!refused.is_skipped());
570        assert!(!refused.is_error());
571        assert!(refused.error().is_none());
572        assert!(refused.refusal().is_some_and(|error| error.is_refusal()));
573        assert!(permission_failure.is_error());
574        assert!(!permission_failure.is_refused());
575        assert!(permission_failure.refusal().is_none());
576        assert!(permission_failure.is_error_kind(ToolErrorKind::PermissionDenied));
577        assert!(!refused.is_error_kind(ToolErrorKind::PermissionDenied));
578        assert_eq!(refused.status_name(), "denied");
579        assert_eq!(permission_failure.status_name(), "error");
580    }
581
582    #[test]
583    fn execution_error_debug_redacts_operator_and_model_payloads() {
584        let error = ToolExecutionError::provider("Bearer secret-operator-message")
585            .with_model_output(ToolOutput::json(serde_json::json!({
586                "credential": "secret-model-output"
587            })))
588            .with_source(Concrete);
589
590        let debug = format!("{error:?}");
591        assert!(debug.contains("kind: Provider"));
592        assert!(debug.contains("model_output: \"<redacted>\""));
593        assert!(debug.contains("source_configured: true"));
594        for secret in [
595            "secret-operator-message",
596            "secret-model-output",
597            "secret detail",
598        ] {
599            assert!(!debug.contains(secret));
600        }
601    }
602
603    #[test]
604    fn debug_redacts_every_tool_result_disposition() {
605        let success = ToolResult::success(ToolOutput::text("secret-success"));
606        let failure = ToolResult::failed(
607            ToolExecutionError::provider("secret-operator").with_model_feedback("secret-model"),
608        );
609        let skipped = ToolResult::skipped("secret-skip");
610        let refused = ToolResult::failed(ToolExecutionError::refused("secret-refusal"));
611
612        for (result, expected_status) in [
613            (success, "success"),
614            (failure, "error"),
615            (skipped, "skipped"),
616            (refused, "denied"),
617        ] {
618            let debug = format!("{result:?}");
619            assert!(debug.contains(expected_status));
620            for secret in [
621                "secret-success",
622                "secret-operator",
623                "secret-model",
624                "secret-skip",
625                "secret-refusal",
626            ] {
627                assert!(!debug.contains(secret));
628            }
629        }
630    }
631}
632
633#[cfg(test)]
634mod migrated_tests {
635    use super::*;
636
637    #[test]
638    fn per_kind_constructors_set_default_retryability() {
639        for (error, retryable) in [
640            (ToolExecutionError::timeout("t"), Some(true)),
641            (ToolExecutionError::rate_limited("r"), Some(true)),
642            (ToolExecutionError::network("n"), Some(true)),
643            (ToolExecutionError::not_found("nf"), Some(false)),
644            (ToolExecutionError::permission_denied("p"), Some(false)),
645            (ToolExecutionError::invalid_args("i"), Some(false)),
646            (ToolExecutionError::cancelled("c"), Some(false)),
647            (ToolExecutionError::provider("p"), None),
648            (ToolExecutionError::other("o"), None),
649        ] {
650            assert_eq!(error.retryable(), retryable);
651        }
652    }
653
654    #[test]
655    fn error_builder_preserves_policy_fields_and_feedback() {
656        let error = ToolExecutionError::rate_limited("operator")
657            .with_model_feedback("slow down")
658            .with_retryable(false)
659            .with_code("RATE_42")
660            .with_http_status(429);
661        assert_eq!(error.kind(), ToolErrorKind::RateLimited);
662        assert_eq!(error.message(), "operator");
663        assert_eq!(error.model_feedback(), Some("slow down"));
664        assert_eq!(error.retryable(), Some(false));
665        assert_eq!(error.code(), Some("RATE_42"));
666        assert_eq!(error.http_status(), Some(429));
667        let result = ToolResult::failed(error);
668        assert_eq!(result.output().as_text(), Some("slow down"));
669        assert!(result.is_error_kind(ToolErrorKind::RateLimited));
670    }
671
672    #[test]
673    fn success_preserves_multiline_output_verbatim() {
674        let result = ToolResult::success(ToolOutput::text("hello\nworld"));
675        assert!(result.is_success());
676        assert_eq!(result.output().as_text(), Some("hello\nworld"));
677        assert!(result.error().is_none());
678    }
679
680    #[test]
681    fn result_states_are_mutually_distinguishable() {
682        let success = ToolResult::success(ToolOutput::text("ok"));
683        let failure = ToolResult::failed(ToolExecutionError::not_found("missing"));
684        let skipped = ToolResult::skipped("policy");
685        let refused = ToolResult::failed(ToolExecutionError::refused("denied"));
686        assert!(success.is_success());
687        assert!(failure.is_error());
688        assert!(skipped.is_skipped());
689        assert!(refused.is_refused());
690        assert!(!refused.is_error());
691        assert!(!skipped.is_refused());
692        assert!(!refused.is_skipped());
693        assert_eq!(success.status_name(), "success");
694        assert_eq!(failure.status_name(), "error");
695        assert_eq!(skipped.status_name(), "skipped");
696        assert_eq!(refused.status_name(), "denied");
697    }
698
699    #[test]
700    fn from_error_keeps_existing_envelope_and_wraps_other_sources() {
701        #[derive(Debug, thiserror::Error)]
702        #[error("boom")]
703        struct Boom;
704        let existing = ToolExecutionError::timeout("slow").with_code("T");
705        let kept = ToolExecutionError::from_error(existing);
706        assert_eq!(kept.kind(), ToolErrorKind::Timeout);
707        assert_eq!(kept.code(), Some("T"));
708        let wrapped = ToolExecutionError::from_error(Boom);
709        assert_eq!(wrapped.kind(), ToolErrorKind::Other);
710        assert!(wrapped.is::<Boom>());
711        assert_eq!(wrapped.message(), "boom");
712        assert_eq!(wrapped.model_feedback(), Some("the tool failed"));
713    }
714
715    #[test]
716    fn from_error_preserves_refusal_disposition() {
717        let refused = ToolExecutionError::from_error(
718            ToolExecutionError::refused("declined").with_code("POLICY"),
719        );
720        assert!(refused.is_refusal());
721        assert_eq!(refused.kind(), ToolErrorKind::PermissionDenied);
722        assert_eq!(refused.code(), Some("POLICY"));
723    }
724}