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)]
136#[serde(from = "ToolExecutionErrorRepr", into = "ToolExecutionErrorRepr")]
137pub struct ToolExecutionError {
138    kind: ToolErrorKind,
139    message: String,
140    model_output: ToolOutput,
141    retryable: Option<bool>,
142    code: Option<String>,
143    http_status: Option<u16>,
144    refusal: bool,
145    /// The concrete source, kept for downcasting on native. Browser wasm
146    /// retains none: its errors are not `Send + Sync`, and this type must be
147    /// (it crosses the effect bus and the wire as part of an `Outcome`).
148    #[cfg(not(target_family = "wasm"))]
149    source: Option<Arc<dyn Error + Send + Sync + 'static>>,
150}
151
152/// The wire form of [`ToolExecutionError`]: every field but `source`. A
153/// source is live process state (a boxed error); it does not cross a wire
154/// and a report reconstructed from one has none.
155#[derive(serde::Serialize, serde::Deserialize)]
156struct ToolExecutionErrorRepr {
157    kind: ToolErrorKind,
158    message: String,
159    model_output: ToolOutput,
160    retryable: Option<bool>,
161    code: Option<String>,
162    http_status: Option<u16>,
163    refusal: bool,
164}
165
166impl From<ToolExecutionError> for ToolExecutionErrorRepr {
167    fn from(error: ToolExecutionError) -> Self {
168        Self {
169            kind: error.kind,
170            message: error.message,
171            model_output: error.model_output,
172            retryable: error.retryable,
173            code: error.code,
174            http_status: error.http_status,
175            refusal: error.refusal,
176        }
177    }
178}
179
180impl From<ToolExecutionErrorRepr> for ToolExecutionError {
181    fn from(repr: ToolExecutionErrorRepr) -> Self {
182        Self {
183            kind: repr.kind,
184            message: repr.message,
185            model_output: repr.model_output,
186            retryable: repr.retryable,
187            code: repr.code,
188            http_status: repr.http_status,
189            refusal: repr.refusal,
190            #[cfg(not(target_family = "wasm"))]
191            source: None,
192        }
193    }
194}
195
196impl ToolExecutionError {
197    /// Construct an error with an explicit normalized kind.
198    pub fn new(kind: ToolErrorKind, message: impl Into<String>) -> Self {
199        let message = message.into();
200        Self {
201            kind,
202            model_output: ToolOutput::text(message.clone()),
203            message,
204            retryable: kind.default_retryable(),
205            code: None,
206            http_status: None,
207            refusal: false,
208            #[cfg(not(target_family = "wasm"))]
209            source: None,
210        }
211    }
212
213    /// An intentional, tool-authored refusal.
214    ///
215    /// Refusals use the normalized [`ToolErrorKind::PermissionDenied`] kind but
216    /// remain distinct from permission failures in [`ToolResult`].
217    pub fn refused(message: impl Into<String>) -> Self {
218        let mut error = Self::new(ToolErrorKind::PermissionDenied, message);
219        error.refusal = true;
220        error
221    }
222
223    /// Build a safely presented `Other` error from a concrete source.
224    ///
225    /// The source's display string remains in [`Self::message`], while the model
226    /// sees stable [`ToolErrorKind::Other`] feedback. Native targets retain the
227    /// downcastable source; WASM drops it. An existing `ToolExecutionError`
228    /// retains its classification and presentation.
229    pub fn from_error<E>(error: E) -> Self
230    where
231        E: Error + WasmCompatSend + WasmCompatSync + 'static,
232    {
233        #[cfg(not(target_family = "wasm"))]
234        {
235            let source: Box<dyn Error + Send + Sync + 'static> = Box::new(error);
236            return match source.downcast::<Self>() {
237                Ok(error) => *error,
238                Err(source) => {
239                    let message = source.to_string();
240                    let mut error = Self::other(message).redact_model_feedback();
241                    error.source = Some(Arc::from(source));
242                    error
243                }
244            };
245        }
246        #[cfg(target_family = "wasm")]
247        {
248            let source: Box<dyn Error + 'static> = Box::new(error);
249            match source.downcast::<Self>() {
250                Ok(error) => *error,
251                Err(source) => Self::other(source.to_string()).redact_model_feedback(),
252            }
253        }
254    }
255
256    /// Replace the model-visible output with literal text feedback.
257    pub fn with_model_feedback(mut self, feedback: impl Into<String>) -> Self {
258        self.model_output = ToolOutput::text(feedback);
259        self
260    }
261
262    /// Replace the model-visible output with canonical JSON or multimodal
263    /// content.
264    pub fn with_model_output(mut self, output: ToolOutput) -> Self {
265        self.model_output = output;
266        self
267    }
268
269    /// Replace potentially sensitive diagnostics with stable, kind-specific
270    /// model feedback.
271    ///
272    /// Explicit error constructors make messages model-visible by default to
273    /// keep failures actionable. Call this when an explicitly constructed
274    /// error's operator diagnostic may contain secrets; [`Self::from_error`]
275    /// already uses this safe presentation for arbitrary source errors.
276    pub(crate) fn redact_model_feedback(mut self) -> Self {
277        self.model_output = ToolOutput::text(self.kind.default_model_feedback());
278        self
279    }
280
281    /// Override the retryability hint.
282    pub fn with_retryable(mut self, retryable: bool) -> Self {
283        self.retryable = Some(retryable);
284        self
285    }
286
287    /// Attach an application/provider code.
288    pub fn with_code(mut self, code: impl Into<String>) -> Self {
289        self.code = Some(code.into());
290        self
291    }
292
293    /// Attach an HTTP status.
294    pub fn with_http_status(mut self, status: u16) -> Self {
295        self.http_status = Some(status);
296        self
297    }
298
299    /// Preserve a concrete source for later downcasting.
300    ///
301    /// Browser wasm keeps no source (see the field): the call is accepted
302    /// and the source dropped, so tool code stays target-independent.
303    #[cfg_attr(target_family = "wasm", allow(unused_mut))]
304    pub fn with_source<E>(mut self, source: E) -> Self
305    where
306        E: Error + WasmCompatSend + WasmCompatSync + 'static,
307    {
308        #[cfg(not(target_family = "wasm"))]
309        {
310            self.source = Some(Arc::new(source));
311        }
312        #[cfg(target_family = "wasm")]
313        {
314            let _ = source;
315        }
316        self
317    }
318
319    /// Normalized kind.
320    pub const fn kind(&self) -> ToolErrorKind {
321        self.kind
322    }
323
324    /// Operator-facing message.
325    pub fn message(&self) -> &str {
326        &self.message
327    }
328
329    /// Literal model feedback, when the presentation is exactly one plain text
330    /// block.
331    ///
332    /// Use [`Self::model_output`] for JSON or multimodal feedback.
333    pub fn model_feedback(&self) -> Option<&str> {
334        self.model_output.as_text()
335    }
336
337    /// Canonical model-visible presentation for this error.
338    pub fn model_output(&self) -> &ToolOutput {
339        &self.model_output
340    }
341
342    /// Retryability hint.
343    pub const fn retryable(&self) -> Option<bool> {
344        self.retryable
345    }
346
347    /// Application/provider code.
348    pub fn code(&self) -> Option<&str> {
349        self.code.as_deref()
350    }
351
352    /// HTTP status.
353    pub const fn http_status(&self) -> Option<u16> {
354        self.http_status
355    }
356
357    /// Whether the tool intentionally refused the operation.
358    pub const fn is_refusal(&self) -> bool {
359        self.refusal
360    }
361
362    /// Downcast the concrete source to `E`.
363    pub fn downcast_ref<E>(&self) -> Option<&E>
364    where
365        E: Error + WasmCompatSend + WasmCompatSync + 'static,
366    {
367        #[cfg(not(target_family = "wasm"))]
368        {
369            self.source.as_ref()?.downcast_ref::<E>()
370        }
371        #[cfg(target_family = "wasm")]
372        {
373            None
374        }
375    }
376
377    /// Whether the concrete source has type `E`.
378    pub fn is<E>(&self) -> bool
379    where
380        E: Error + WasmCompatSend + WasmCompatSync + 'static,
381    {
382        self.downcast_ref::<E>().is_some()
383    }
384}
385
386impl std::fmt::Display for ToolExecutionError {
387    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
388        f.write_str(&self.message)
389    }
390}
391
392impl std::fmt::Debug for ToolExecutionError {
393    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
394        f.debug_struct("ToolExecutionError")
395            .field("kind", &self.kind)
396            .field("retryable", &self.retryable)
397            .field("code", &self.code)
398            .field("http_status", &self.http_status)
399            .field("refusal", &self.refusal)
400            .field("model_output", &"<redacted>")
401            .field("source_configured", &self.has_source())
402            .finish()
403    }
404}
405
406impl ToolExecutionError {
407    fn has_source(&self) -> bool {
408        #[cfg(not(target_family = "wasm"))]
409        {
410            self.source.is_some()
411        }
412        #[cfg(target_family = "wasm")]
413        {
414            false
415        }
416    }
417}
418
419impl Error for ToolExecutionError {
420    fn source(&self) -> Option<&(dyn Error + 'static)> {
421        #[cfg(not(target_family = "wasm"))]
422        {
423            self.source
424                .as_deref()
425                .map(|source| source as &(dyn Error + 'static))
426        }
427        #[cfg(target_family = "wasm")]
428        {
429            None
430        }
431    }
432}
433
434/// Private mutually exclusive state behind [`ToolResult`].
435#[derive(Clone, serde::Serialize, serde::Deserialize)]
436#[serde(tag = "status", content = "value", rename_all = "snake_case")]
437enum ToolDisposition {
438    Success(ToolOutput),
439    Error(ToolExecutionError),
440    Refused(ToolExecutionError),
441    Skipped(ToolOutput),
442}
443
444/// The single structured execution view used by dispatch, hooks, and telemetry.
445///
446/// Each result has exactly one disposition. The tagged state is private so tool
447/// authors keep returning ordinary `Result` values while runtime callers use
448/// the stable query methods on this type.
449#[derive(Clone, serde::Serialize, serde::Deserialize)]
450#[serde(transparent)]
451pub struct ToolResult {
452    disposition: ToolDisposition,
453}
454
455impl std::fmt::Debug for ToolResult {
456    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
457        let error = match &self.disposition {
458            ToolDisposition::Error(error) | ToolDisposition::Refused(error) => Some(error),
459            ToolDisposition::Success(_) | ToolDisposition::Skipped(_) => None,
460        };
461        formatter
462            .debug_struct("ToolResult")
463            .field("status", &self.status_name())
464            .field("error_kind", &error.map(ToolExecutionError::kind))
465            .field("retryable", &error.and_then(ToolExecutionError::retryable))
466            .field("code", &error.and_then(ToolExecutionError::code))
467            .field(
468                "http_status",
469                &error.and_then(ToolExecutionError::http_status),
470            )
471            .finish()
472    }
473}
474
475impl ToolResult {
476    /// Creates a successful canonical tool result.
477    pub fn success(output: ToolOutput) -> Self {
478        Self {
479            disposition: ToolDisposition::Success(output),
480        }
481    }
482
483    /// Creates a failed or refused canonical tool result.
484    pub fn failed(error: ToolExecutionError) -> Self {
485        let disposition = if error.is_refusal() {
486            ToolDisposition::Refused(error)
487        } else {
488            ToolDisposition::Error(error)
489        };
490        Self { disposition }
491    }
492
493    /// Creates a result for a call skipped by runtime policy.
494    pub fn skipped(reason: impl Into<String>) -> Self {
495        Self {
496            disposition: ToolDisposition::Skipped(ToolOutput::text(reason)),
497        }
498    }
499
500    /// The same result with its model-visible output replaced, keeping its
501    /// disposition: a success or a skip carries the new output; a failure or
502    /// refusal keeps its error and presents the new output to the model.
503    pub fn with_output(self, output: ToolOutput) -> Self {
504        let disposition = match self.disposition {
505            ToolDisposition::Success(_) => ToolDisposition::Success(output),
506            ToolDisposition::Skipped(_) => ToolDisposition::Skipped(output),
507            ToolDisposition::Error(error) => {
508                ToolDisposition::Error(error.with_model_output(output))
509            }
510            ToolDisposition::Refused(error) => {
511                ToolDisposition::Refused(error.with_model_output(output))
512            }
513        };
514        Self { disposition }
515    }
516
517    /// Current model-visible output, including replacements made by [`Self::with_output`].
518    pub fn output(&self) -> &ToolOutput {
519        match &self.disposition {
520            ToolDisposition::Success(output) | ToolDisposition::Skipped(output) => output,
521            ToolDisposition::Error(error) | ToolDisposition::Refused(error) => error.model_output(),
522        }
523    }
524
525    /// Structured execution error, if execution failed.
526    ///
527    /// Intentional refusals are available through [`Self::refusal`] instead.
528    pub fn error(&self) -> Option<&ToolExecutionError> {
529        match &self.disposition {
530            ToolDisposition::Error(error) => Some(error),
531            ToolDisposition::Success(_)
532            | ToolDisposition::Refused(_)
533            | ToolDisposition::Skipped(_) => None,
534        }
535    }
536
537    /// Structured refusal details, if the tool intentionally declined the call.
538    ///
539    /// This is mutually exclusive with [`Self::error`].
540    pub fn refusal(&self) -> Option<&ToolExecutionError> {
541        match &self.disposition {
542            ToolDisposition::Refused(error) => Some(error),
543            ToolDisposition::Success(_)
544            | ToolDisposition::Error(_)
545            | ToolDisposition::Skipped(_) => None,
546        }
547    }
548
549    /// Whether the tool completed successfully.
550    pub fn is_success(&self) -> bool {
551        matches!(&self.disposition, ToolDisposition::Success(_))
552    }
553
554    /// Whether execution failed.
555    ///
556    /// An intentional refusal is not an execution error; inspect
557    /// [`Self::is_refused`] instead.
558    pub fn is_error(&self) -> bool {
559        matches!(&self.disposition, ToolDisposition::Error(_))
560    }
561
562    /// Whether the framework skipped execution before the tool body ran.
563    pub fn is_skipped(&self) -> bool {
564        matches!(&self.disposition, ToolDisposition::Skipped(_))
565    }
566
567    /// Whether a tool refused execution.
568    pub fn is_refused(&self) -> bool {
569        matches!(&self.disposition, ToolDisposition::Refused(_))
570    }
571
572    /// Whether this is an error of exactly `kind`.
573    ///
574    /// A refusal does not match, even though its envelope uses the normalized
575    /// [`ToolErrorKind::PermissionDenied`] kind.
576    pub fn is_error_kind(&self, kind: ToolErrorKind) -> bool {
577        self.error().is_some_and(|error| error.kind == kind)
578    }
579
580    /// Converts success or skipped dispositions to `Ok(output)` and errors or
581    /// refusals to `Err(error)`.
582    pub fn into_result(self) -> Result<ToolOutput, ToolExecutionError> {
583        match self.disposition {
584            ToolDisposition::Success(output) | ToolDisposition::Skipped(output) => Ok(output),
585            ToolDisposition::Error(error) | ToolDisposition::Refused(error) => Err(error),
586        }
587    }
588
589    /// Stable telemetry disposition: `success`, `error`, `denied`, or `skipped`.
590    pub fn status_name(&self) -> &'static str {
591        match &self.disposition {
592            ToolDisposition::Success(_) => "success",
593            ToolDisposition::Error(_) => "error",
594            ToolDisposition::Refused(_) => "denied",
595            ToolDisposition::Skipped(_) => "skipped",
596        }
597    }
598}
599
600#[cfg(not(target_family = "wasm"))]
601const _: fn() = || {
602    fn assert_send_sync<T: Send + Sync>() {}
603    assert_send_sync::<ToolExecutionError>();
604    assert_send_sync::<ToolResult>();
605};
606
607#[cfg(test)]
608mod tests;
609
610#[cfg(test)]
611mod migrated_tests;