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