Skip to main content

rig_core/tool/
result.rs

1//! Structured tool failures and model-visible execution results.
2//!
3//! ```
4//! use rig_core::tool::{ToolExecutionError, ToolResult};
5//!
6//! let result = ToolResult::failed(ToolExecutionError::invalid_args("Provide a name"));
7//! assert!(result.is_error());
8//! assert_eq!(result.output().as_text(), Some("Provide a name"));
9//! ```
10
11use std::error::Error;
12#[cfg(not(target_family = "wasm"))]
13use std::sync::Arc;
14
15use crate::{
16    tool::ToolOutput,
17    wasm_compat::{WasmCompatSend, WasmCompatSync},
18};
19
20/// Normalized classification for a tool execution error.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum ToolErrorKind {
24    /// Arguments could not be decoded or validated.
25    InvalidArgs,
26    /// Execution exceeded its deadline.
27    Timeout,
28    /// Execution was cancelled.
29    Cancelled,
30    /// The requested tool or resource was not found.
31    NotFound,
32    /// An authorization or permission check failed. Intentional tool refusals
33    /// use this normalized kind with a separate refusal disposition.
34    PermissionDenied,
35    /// A rate limit was reached.
36    RateLimited,
37    /// An upstream provider failed.
38    Provider,
39    /// A network operation failed.
40    Network,
41    /// Any other failure.
42    Other,
43}
44
45// One row per kind: (stable name, default retryability, default model
46// feedback). The macro emits the three parallel accessors from the single
47// table so a new kind cannot update one and miss another.
48macro_rules! kind_defaults {
49    ($($variant:ident => ($name:literal, $retryable:expr, $feedback:literal)),+ $(,)?) => {
50        impl ToolErrorKind {
51            /// Stable machine-readable name.
52            pub const fn as_str(self) -> &'static str {
53                match self { $(Self::$variant => $name,)+ }
54            }
55
56            /// Default retryability, or `None` when the tool must supply a verdict.
57            /// Without an override, `None` becomes non-retryable in error reports.
58            pub const fn default_retryable(self) -> Option<bool> {
59                match self { $(Self::$variant => $retryable,)+ }
60            }
61
62            const fn default_model_feedback(self) -> &'static str {
63                match self { $(Self::$variant => $feedback,)+ }
64            }
65        }
66    };
67}
68
69kind_defaults! {
70    InvalidArgs => ("invalid_args", Some(false), "tool arguments were invalid"),
71    Timeout => ("timeout", Some(true), "tool execution timed out"),
72    Cancelled => ("cancelled", Some(false), "tool execution was cancelled"),
73    NotFound => ("not_found", Some(false), "the requested tool or resource was not found"),
74    PermissionDenied => ("permission_denied", Some(false), "the tool denied the request"),
75    RateLimited => ("rate_limited", Some(true), "the tool was rate limited; try again later"),
76    Provider => ("provider", None, "the tool provider failed"),
77    Network => ("network", Some(true), "the tool could not reach its upstream service"),
78    Other => ("other", None, "the tool failed"),
79}
80
81// One `ToolExecutionError` constructor per kind, from a single table so a new
82// kind cannot miss its shorthand. `refused` stays hand-written because it also
83// sets the refusal disposition.
84macro_rules! kind_ctors {
85    ($($(#[$doc:meta])* $ctor:ident => $variant:ident),+ $(,)?) => {
86        impl ToolExecutionError {
87            $($(#[$doc])*
88            pub fn $ctor(message: impl Into<String>) -> Self {
89                Self::new(ToolErrorKind::$variant, message)
90            })+
91        }
92    };
93}
94
95kind_ctors! {
96    /// Invalid arguments.
97    invalid_args => InvalidArgs,
98    /// Timeout.
99    timeout => Timeout,
100    /// Cancellation.
101    cancelled => Cancelled,
102    /// Missing tool or resource.
103    not_found => NotFound,
104    /// An authorization or permission failure.
105    ///
106    /// This is an ordinary execution error. Use [`Self::refused`] when the tool
107    /// intentionally declines the operation so hooks and telemetry can preserve
108    /// the refusal as a distinct disposition.
109    permission_denied => PermissionDenied,
110    /// Rate limit.
111    rate_limited => RateLimited,
112    /// Upstream provider failure.
113    provider => Provider,
114    /// Network failure.
115    network => Network,
116    /// Catch-all failure.
117    other => Other,
118}
119
120impl std::fmt::Display for ToolErrorKind {
121    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122        f.write_str(self.as_str())
123    }
124}
125
126/// One public envelope for every tool execution failure.
127///
128/// It carries normalized policy fields, separate operator-facing diagnostics
129/// and model-visible output, and an optional concrete source that can be
130/// downcast. Explicit constructors use the diagnostic message as model-visible
131/// output so deliberately authored validation failures remain actionable.
132/// [`Self::from_error`] instead treats an arbitrary source as operator-only and
133/// exposes safe kind-level feedback. Use [`Self::with_model_feedback`] or
134/// [`Self::with_model_output`] to provide a purpose-built presentation.
135#[derive(Clone, serde::Serialize, serde::Deserialize)]
136pub struct ToolExecutionError {
137    kind: ToolErrorKind,
138    message: String,
139    model_output: ToolOutput,
140    retryable: Option<bool>,
141    code: Option<String>,
142    http_status: Option<u16>,
143    refusal: bool,
144    /// The concrete source, kept for downcasting on native. Browser wasm
145    /// retains none: its errors are not `Send + Sync`, and this type must be
146    /// (it crosses the effect bus and the wire as part of an `Outcome`).
147    /// Live process state never crosses a wire, so a deserialized error has
148    /// none.
149    #[cfg(not(target_family = "wasm"))]
150    #[serde(skip)]
151    source: Option<Arc<dyn Error + Send + Sync + 'static>>,
152}
153
154impl ToolExecutionError {
155    /// Construct an error with an explicit normalized kind.
156    pub fn new(kind: ToolErrorKind, message: impl Into<String>) -> Self {
157        let message = message.into();
158        Self {
159            kind,
160            model_output: ToolOutput::text(message.clone()),
161            message,
162            retryable: kind.default_retryable(),
163            code: None,
164            http_status: None,
165            refusal: false,
166            #[cfg(not(target_family = "wasm"))]
167            source: None,
168        }
169    }
170
171    /// An intentional, tool-authored refusal.
172    ///
173    /// Refusals use the normalized [`ToolErrorKind::PermissionDenied`] kind but
174    /// remain distinct from permission failures in [`ToolResult`].
175    pub fn refused(message: impl Into<String>) -> Self {
176        let mut error = Self::new(ToolErrorKind::PermissionDenied, message);
177        error.refusal = true;
178        error
179    }
180
181    /// Build a safely presented `Other` error from a concrete source.
182    ///
183    /// The source's display string remains in [`Self::message`], while the model
184    /// sees stable [`ToolErrorKind::Other`] feedback. Native targets retain the
185    /// downcastable source; WASM drops it. An existing `ToolExecutionError`
186    /// retains 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) => Self::other(source.to_string()).redact_model_feedback(),
210            }
211        }
212    }
213
214    /// Replace the model-visible output with literal text feedback.
215    pub fn with_model_feedback(mut self, feedback: impl Into<String>) -> Self {
216        self.model_output = ToolOutput::text(feedback);
217        self
218    }
219
220    /// Replace the model-visible output with canonical JSON or multimodal
221    /// content.
222    pub fn with_model_output(mut self, output: ToolOutput) -> Self {
223        self.model_output = output;
224        self
225    }
226
227    /// Replace potentially sensitive diagnostics with stable, kind-specific
228    /// model feedback.
229    ///
230    /// Explicit error constructors make messages model-visible by default to
231    /// keep failures actionable. Call this when an explicitly constructed
232    /// error's operator diagnostic may contain secrets; [`Self::from_error`]
233    /// already uses this safe presentation for arbitrary source errors.
234    pub(crate) fn redact_model_feedback(mut self) -> Self {
235        self.model_output = ToolOutput::text(self.kind.default_model_feedback());
236        self
237    }
238
239    /// Override the retryability hint.
240    pub fn with_retryable(mut self, retryable: bool) -> Self {
241        self.retryable = Some(retryable);
242        self
243    }
244
245    /// Attach an application/provider code.
246    pub fn with_code(mut self, code: impl Into<String>) -> Self {
247        self.code = Some(code.into());
248        self
249    }
250
251    /// Attach an HTTP status.
252    pub fn with_http_status(mut self, status: u16) -> Self {
253        self.http_status = Some(status);
254        self
255    }
256
257    /// Preserve a concrete source for later downcasting.
258    ///
259    /// Browser wasm keeps no source (see the field): the call is accepted
260    /// and the source dropped, so tool code stays target-independent.
261    #[cfg_attr(target_family = "wasm", allow(unused_mut))]
262    pub fn with_source<E>(mut self, source: E) -> Self
263    where
264        E: Error + WasmCompatSend + WasmCompatSync + 'static,
265    {
266        #[cfg(not(target_family = "wasm"))]
267        {
268            self.source = Some(Arc::new(source));
269        }
270        #[cfg(target_family = "wasm")]
271        {
272            let _ = source;
273        }
274        self
275    }
276
277    /// Normalized kind.
278    pub const fn kind(&self) -> ToolErrorKind {
279        self.kind
280    }
281
282    /// Operator-facing message.
283    pub fn message(&self) -> &str {
284        &self.message
285    }
286
287    /// Literal model feedback, when the presentation is exactly one plain text
288    /// block.
289    ///
290    /// Use [`Self::model_output`] for JSON or multimodal feedback.
291    pub fn model_feedback(&self) -> Option<&str> {
292        self.model_output.as_text()
293    }
294
295    /// Canonical model-visible presentation for this error.
296    pub fn model_output(&self) -> &ToolOutput {
297        &self.model_output
298    }
299
300    /// Retryability hint.
301    pub const fn retryable(&self) -> Option<bool> {
302        self.retryable
303    }
304
305    /// Application/provider code.
306    pub fn code(&self) -> Option<&str> {
307        self.code.as_deref()
308    }
309
310    /// HTTP status.
311    pub const fn http_status(&self) -> Option<u16> {
312        self.http_status
313    }
314
315    /// Whether the tool intentionally refused the operation.
316    pub const fn is_refusal(&self) -> bool {
317        self.refusal
318    }
319
320    /// Downcast the concrete source to `E`.
321    pub fn downcast_ref<E>(&self) -> Option<&E>
322    where
323        E: Error + WasmCompatSend + WasmCompatSync + 'static,
324    {
325        #[cfg(not(target_family = "wasm"))]
326        {
327            self.source.as_ref()?.downcast_ref::<E>()
328        }
329        #[cfg(target_family = "wasm")]
330        {
331            None
332        }
333    }
334
335    /// Whether the concrete source has type `E`.
336    pub fn is<E>(&self) -> bool
337    where
338        E: Error + WasmCompatSend + WasmCompatSync + 'static,
339    {
340        self.downcast_ref::<E>().is_some()
341    }
342}
343
344impl std::fmt::Display for ToolExecutionError {
345    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
346        f.write_str(&self.message)
347    }
348}
349
350impl std::fmt::Debug for ToolExecutionError {
351    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
352        f.debug_struct("ToolExecutionError")
353            .field("kind", &self.kind)
354            .field("retryable", &self.retryable)
355            .field("code", &self.code)
356            .field("http_status", &self.http_status)
357            .field("refusal", &self.refusal)
358            .field("model_output", &"<redacted>")
359            .field("source_configured", &self.has_source())
360            .finish()
361    }
362}
363
364impl ToolExecutionError {
365    fn has_source(&self) -> bool {
366        #[cfg(not(target_family = "wasm"))]
367        {
368            self.source.is_some()
369        }
370        #[cfg(target_family = "wasm")]
371        {
372            false
373        }
374    }
375}
376
377impl Error for ToolExecutionError {
378    fn source(&self) -> Option<&(dyn Error + 'static)> {
379        #[cfg(not(target_family = "wasm"))]
380        {
381            self.source
382                .as_deref()
383                .map(|source| source as &(dyn Error + 'static))
384        }
385        #[cfg(target_family = "wasm")]
386        {
387            None
388        }
389    }
390}
391
392/// Private mutually exclusive state behind [`ToolResult`].
393#[derive(Clone, serde::Serialize, serde::Deserialize)]
394#[serde(tag = "status", content = "value", rename_all = "snake_case")]
395enum ToolDisposition {
396    Success(ToolOutput),
397    Error(ToolExecutionError),
398    Refused(ToolExecutionError),
399    Skipped(ToolOutput),
400}
401
402/// The single structured execution view used by dispatch, hooks, and telemetry.
403///
404/// Each result has exactly one disposition. The tagged state is private so tool
405/// authors keep returning ordinary `Result` values while runtime callers use
406/// the stable query methods on this type.
407#[derive(Clone, serde::Serialize, serde::Deserialize)]
408#[serde(transparent)]
409pub struct ToolResult {
410    disposition: ToolDisposition,
411}
412
413impl std::fmt::Debug for ToolResult {
414    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
415        let error = match &self.disposition {
416            ToolDisposition::Error(error) | ToolDisposition::Refused(error) => Some(error),
417            ToolDisposition::Success(_) | ToolDisposition::Skipped(_) => None,
418        };
419        formatter
420            .debug_struct("ToolResult")
421            .field("status", &self.status_name())
422            .field("error_kind", &error.map(ToolExecutionError::kind))
423            .field("retryable", &error.and_then(ToolExecutionError::retryable))
424            .field("code", &error.and_then(ToolExecutionError::code))
425            .field(
426                "http_status",
427                &error.and_then(ToolExecutionError::http_status),
428            )
429            .finish()
430    }
431}
432
433impl ToolResult {
434    /// Creates a successful canonical tool result.
435    pub fn success(output: ToolOutput) -> Self {
436        Self {
437            disposition: ToolDisposition::Success(output),
438        }
439    }
440
441    /// Creates a failed or refused canonical tool result.
442    pub fn failed(error: ToolExecutionError) -> Self {
443        let disposition = if error.is_refusal() {
444            ToolDisposition::Refused(error)
445        } else {
446            ToolDisposition::Error(error)
447        };
448        Self { disposition }
449    }
450
451    /// Creates a result for a call skipped by runtime policy.
452    pub fn skipped(reason: impl Into<String>) -> Self {
453        Self {
454            disposition: ToolDisposition::Skipped(ToolOutput::text(reason)),
455        }
456    }
457
458    /// The same result with its model-visible output replaced, keeping its
459    /// disposition: a success or a skip carries the new output; a failure or
460    /// refusal keeps its error and presents the new output to the model.
461    pub fn with_output(self, output: ToolOutput) -> Self {
462        let disposition = match self.disposition {
463            ToolDisposition::Success(_) => ToolDisposition::Success(output),
464            ToolDisposition::Skipped(_) => ToolDisposition::Skipped(output),
465            ToolDisposition::Error(error) => {
466                ToolDisposition::Error(error.with_model_output(output))
467            }
468            ToolDisposition::Refused(error) => {
469                ToolDisposition::Refused(error.with_model_output(output))
470            }
471        };
472        Self { disposition }
473    }
474
475    /// Current model-visible output, including replacements made by [`Self::with_output`].
476    pub fn output(&self) -> &ToolOutput {
477        match &self.disposition {
478            ToolDisposition::Success(output) | ToolDisposition::Skipped(output) => output,
479            ToolDisposition::Error(error) | ToolDisposition::Refused(error) => error.model_output(),
480        }
481    }
482
483    /// Structured execution error, if execution failed.
484    ///
485    /// Intentional refusals are available through [`Self::refusal`] instead.
486    pub fn error(&self) -> Option<&ToolExecutionError> {
487        match &self.disposition {
488            ToolDisposition::Error(error) => Some(error),
489            ToolDisposition::Success(_)
490            | ToolDisposition::Refused(_)
491            | ToolDisposition::Skipped(_) => None,
492        }
493    }
494
495    /// Structured refusal details, if the tool intentionally declined the call.
496    ///
497    /// This is mutually exclusive with [`Self::error`].
498    pub fn refusal(&self) -> Option<&ToolExecutionError> {
499        match &self.disposition {
500            ToolDisposition::Refused(error) => Some(error),
501            ToolDisposition::Success(_)
502            | ToolDisposition::Error(_)
503            | ToolDisposition::Skipped(_) => None,
504        }
505    }
506
507    /// Whether the tool completed successfully.
508    pub fn is_success(&self) -> bool {
509        matches!(&self.disposition, ToolDisposition::Success(_))
510    }
511
512    /// Whether execution failed.
513    ///
514    /// An intentional refusal is not an execution error; inspect
515    /// [`Self::is_refused`] instead.
516    pub fn is_error(&self) -> bool {
517        matches!(&self.disposition, ToolDisposition::Error(_))
518    }
519
520    /// Whether the framework skipped execution before the tool body ran.
521    pub fn is_skipped(&self) -> bool {
522        matches!(&self.disposition, ToolDisposition::Skipped(_))
523    }
524
525    /// Whether a tool refused execution.
526    pub fn is_refused(&self) -> bool {
527        matches!(&self.disposition, ToolDisposition::Refused(_))
528    }
529
530    /// Whether this is an error of exactly `kind`.
531    ///
532    /// A refusal does not match, even though its envelope uses the normalized
533    /// [`ToolErrorKind::PermissionDenied`] kind.
534    pub fn is_error_kind(&self, kind: ToolErrorKind) -> bool {
535        self.error().is_some_and(|error| error.kind == kind)
536    }
537
538    /// Converts success or skipped dispositions to `Ok(output)` and errors or
539    /// refusals to `Err(error)`.
540    pub fn into_result(self) -> Result<ToolOutput, ToolExecutionError> {
541        match self.disposition {
542            ToolDisposition::Success(output) | ToolDisposition::Skipped(output) => Ok(output),
543            ToolDisposition::Error(error) | ToolDisposition::Refused(error) => Err(error),
544        }
545    }
546
547    /// Stable telemetry disposition: `success`, `error`, `denied`, or `skipped`.
548    pub fn status_name(&self) -> &'static str {
549        match &self.disposition {
550            ToolDisposition::Success(_) => "success",
551            ToolDisposition::Error(_) => "error",
552            ToolDisposition::Refused(_) => "denied",
553            ToolDisposition::Skipped(_) => "skipped",
554        }
555    }
556}
557
558#[cfg(not(target_family = "wasm"))]
559const _: fn() = || {
560    fn assert_send_sync<T: Send + Sync>() {}
561    assert_send_sync::<ToolExecutionError>();
562    assert_send_sync::<ToolResult>();
563};
564
565#[cfg(test)]
566mod tests;