Skip to main content

rig_core/tool/
result.rs

1//! Structured tool-execution results.
2//!
3//! Rig separates three things a tool execution produces, so hooks, tracing,
4//! telemetry, and policies never have to parse the model-visible string to
5//! reason about what happened:
6//!
7//! 1. **model-visible output** — the text the LLM receives ([`ToolExecutionResult::model_output`]);
8//! 2. **a structured outcome** — success, a classified failure, skipped, or
9//!    denied ([`ToolOutcome`]);
10//! 3. **type-erased extensions** — provider/application metadata that is *never*
11//!    sent to the model ([`ToolResultExtensions`](crate::tool::ToolResultExtensions)).
12//!
13//! A tool author returns a [`ToolReturn`] to attach an outcome or extensions to a
14//! successful output; the [`Tool::classify_error`](crate::tool::Tool::classify_error)
15//! hook maps a tool's own error type to a [`ToolFailure`] without string parsing.
16//! The dynamic tool boundary ([`ToolDyn`](crate::tool::ToolDyn)) carries the
17//! resulting [`ToolExecutionResult`] all the way through to the
18//! [`StepEvent::ToolResult`](crate::agent::StepEvent::ToolResult) hook event.
19
20use crate::tool::ToolResultExtensions;
21use serde::Serialize;
22
23/// How a tool execution failed, as a closed set of standard kinds.
24///
25/// A hook, policy, or telemetry pipeline matches on this to make control-flow
26/// decisions (e.g. "terminate after repeated timeouts, but keep going on a 404")
27/// without parsing the model-visible error string.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
29#[non_exhaustive]
30pub enum ToolFailureKind {
31    /// The arguments could not be parsed or were rejected as invalid.
32    InvalidArgs,
33    /// The tool did not complete within its allotted time.
34    Timeout,
35    /// The tool call was cancelled (e.g. by an abort signal or a dropped future).
36    Cancelled,
37    /// The requested resource was not found (e.g. an HTTP 404).
38    NotFound,
39    /// The caller was not permitted to perform the action (e.g. an HTTP 401/403).
40    PermissionDenied,
41    /// The tool was rate limited (e.g. an HTTP 429).
42    RateLimited,
43    /// The upstream provider/service returned an error (e.g. an HTTP 5xx).
44    Provider,
45    /// A network/transport failure occurred before a response was received.
46    Network,
47    /// Any failure that does not fit a more specific kind.
48    Other,
49}
50
51impl ToolFailureKind {
52    /// A stable, machine-friendly identifier for the kind, suitable for tracing
53    /// spans, metrics labels, and structured logs.
54    pub const fn as_str(self) -> &'static str {
55        match self {
56            ToolFailureKind::InvalidArgs => "invalid_args",
57            ToolFailureKind::Timeout => "timeout",
58            ToolFailureKind::Cancelled => "cancelled",
59            ToolFailureKind::NotFound => "not_found",
60            ToolFailureKind::PermissionDenied => "permission_denied",
61            ToolFailureKind::RateLimited => "rate_limited",
62            ToolFailureKind::Provider => "provider",
63            ToolFailureKind::Network => "network",
64            ToolFailureKind::Other => "other",
65        }
66    }
67
68    /// A sensible default for whether a failure of this kind is worth retrying,
69    /// used by the per-kind constructors on [`ToolFailure`]. Transient kinds
70    /// (timeout, rate-limited, network) default to retryable; kinds that will
71    /// fail again identically (invalid args, not found, permission denied,
72    /// cancelled) default to not retryable; ambiguous kinds default to unknown.
73    const fn default_retryable(self) -> Option<bool> {
74        match self {
75            ToolFailureKind::Timeout | ToolFailureKind::RateLimited | ToolFailureKind::Network => {
76                Some(true)
77            }
78            ToolFailureKind::InvalidArgs
79            | ToolFailureKind::NotFound
80            | ToolFailureKind::PermissionDenied
81            | ToolFailureKind::Cancelled => Some(false),
82            ToolFailureKind::Provider | ToolFailureKind::Other => None,
83        }
84    }
85}
86
87impl std::fmt::Display for ToolFailureKind {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        f.write_str(self.as_str())
90    }
91}
92
93/// A structured, model-independent description of a tool execution failure.
94///
95/// This is the "machine-visible" half of a failed tool call. The model-visible
96/// text lives separately on [`ToolExecutionResult::model_output`]; this carries
97/// the classification a hook or policy acts on. Build one with the per-kind
98/// constructors (which pre-fill a sensible [`retryable`](Self::retryable) default)
99/// and refine it with the builder setters:
100///
101/// ```
102/// use rig_core::tool::{ToolFailure, ToolFailureKind};
103///
104/// let failure = ToolFailure::not_found("user 42 does not exist")
105///     .with_http_status(404)
106///     .with_code("USER_NOT_FOUND");
107/// assert_eq!(failure.kind, ToolFailureKind::NotFound);
108/// assert_eq!(failure.retryable, Some(false));
109/// assert_eq!(failure.http_status, Some(404));
110/// ```
111#[derive(Debug, Clone, PartialEq, Eq)]
112#[non_exhaustive]
113pub struct ToolFailure {
114    /// The standard classification of the failure.
115    pub kind: ToolFailureKind,
116    /// A human-readable description of what went wrong. This is *not*
117    /// automatically shown to the model — it is metadata for logs and policies.
118    pub message: String,
119    /// Whether retrying the call could plausibly succeed. `None` means unknown.
120    pub retryable: Option<bool>,
121    /// An optional machine-readable error code (e.g. a provider error code).
122    pub code: Option<String>,
123    /// An optional HTTP status code, when the failure originated from an HTTP call.
124    pub http_status: Option<u16>,
125}
126
127impl ToolFailure {
128    /// Construct a failure of the given `kind` with `message`. `retryable`,
129    /// `code`, and `http_status` start unset — use the builder setters or a
130    /// per-kind constructor to fill them.
131    pub fn new(kind: ToolFailureKind, message: impl Into<String>) -> Self {
132        Self {
133            kind,
134            message: message.into(),
135            retryable: None,
136            code: None,
137            http_status: None,
138        }
139    }
140
141    /// Construct a failure of `kind` with `message` and the kind's default
142    /// [`retryable`](Self::retryable) hint. The per-kind constructors delegate here.
143    pub(crate) fn of_kind(kind: ToolFailureKind, message: impl Into<String>) -> Self {
144        Self {
145            retryable: kind.default_retryable(),
146            ..Self::new(kind, message)
147        }
148    }
149
150    /// An [`InvalidArgs`](ToolFailureKind::InvalidArgs) failure.
151    pub fn invalid_args(message: impl Into<String>) -> Self {
152        Self::of_kind(ToolFailureKind::InvalidArgs, message)
153    }
154
155    /// A [`Timeout`](ToolFailureKind::Timeout) failure.
156    pub fn timeout(message: impl Into<String>) -> Self {
157        Self::of_kind(ToolFailureKind::Timeout, message)
158    }
159
160    /// A [`Cancelled`](ToolFailureKind::Cancelled) failure.
161    pub fn cancelled(message: impl Into<String>) -> Self {
162        Self::of_kind(ToolFailureKind::Cancelled, message)
163    }
164
165    /// A [`NotFound`](ToolFailureKind::NotFound) failure.
166    pub fn not_found(message: impl Into<String>) -> Self {
167        Self::of_kind(ToolFailureKind::NotFound, message)
168    }
169
170    /// A [`PermissionDenied`](ToolFailureKind::PermissionDenied) failure.
171    pub fn permission_denied(message: impl Into<String>) -> Self {
172        Self::of_kind(ToolFailureKind::PermissionDenied, message)
173    }
174
175    /// A [`RateLimited`](ToolFailureKind::RateLimited) failure.
176    pub fn rate_limited(message: impl Into<String>) -> Self {
177        Self::of_kind(ToolFailureKind::RateLimited, message)
178    }
179
180    /// A [`Provider`](ToolFailureKind::Provider) failure.
181    pub fn provider(message: impl Into<String>) -> Self {
182        Self::of_kind(ToolFailureKind::Provider, message)
183    }
184
185    /// A [`Network`](ToolFailureKind::Network) failure.
186    pub fn network(message: impl Into<String>) -> Self {
187        Self::of_kind(ToolFailureKind::Network, message)
188    }
189
190    /// An [`Other`](ToolFailureKind::Other) failure — the catch-all.
191    pub fn other(message: impl Into<String>) -> Self {
192        Self::of_kind(ToolFailureKind::Other, message)
193    }
194
195    /// Set whether the failure is retryable.
196    pub fn with_retryable(mut self, retryable: bool) -> Self {
197        self.retryable = Some(retryable);
198        self
199    }
200
201    /// Set a machine-readable error code.
202    pub fn with_code(mut self, code: impl Into<String>) -> Self {
203        self.code = Some(code.into());
204        self
205    }
206
207    /// Set the originating HTTP status code.
208    pub fn with_http_status(mut self, status: u16) -> Self {
209        self.http_status = Some(status);
210        self
211    }
212}
213
214impl std::fmt::Display for ToolFailure {
215    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
216        write!(f, "{}: {}", self.kind, self.message)
217    }
218}
219
220impl std::error::Error for ToolFailure {}
221
222/// The structured outcome of a tool call: what happened, independent of the
223/// model-visible text.
224///
225/// This is Rig's answer to "was that a success or an error?" without inspecting
226/// the result string. It mirrors Pydantic AI's `outcome: 'success' | 'failed' |
227/// 'denied'` and the Vercel AI SDK's `tool-result` vs `tool-error` distinction.
228///
229/// # `Skipped` vs `Denied`
230///
231/// Both mean the tool body did not run, but they come from opposite sides — the
232/// framework vs. the tool — and are not synonyms:
233///
234/// - [`Skipped`](Self::Skipped) is produced **by the framework** when a
235///   [`ToolCall`](crate::agent::StepEvent::ToolCall) hook returns
236///   [`Flow::Skip`](crate::agent::Flow::Skip). **Approval-policy denials use
237///   `Flow::Skip`, so they surface as `Skipped`, not `Denied`** — there is no
238///   `Flow::Deny`. A policy that wants to distinguish its denials from other
239///   skips can key off the skip reason it supplied, or attach its own metadata.
240/// - [`Denied`](Self::Denied) is authored **by the tool**, via
241///   [`ToolReturn::denied`] / [`ToolExecutionResult::denied`] — the tool ran its
242///   own check and refused the call (e.g. an internal authorization check). This
243///   is the tool-side counterpart to a hook skip: **tools express refusal as
244///   `Denied`, not `Skipped`** (there is no tool-authored skip constructor).
245///
246/// So [`is_skipped`](Self::is_skipped) means "a hook skipped the call" and
247/// [`is_denied`](Self::is_denied) means "the tool refused it" — unambiguously,
248/// because the split is enforced by the type system: a tool authors a
249/// [`ToolReturnOutcome`], which has no `Skipped` variant, and the observed
250/// `Skipped` outcome can be produced only inside the crate (the framework). A
251/// tool cannot construct a return or a [`ToolExecutionResult`] that claims to
252/// have been skipped.
253#[derive(Debug, Clone, PartialEq, Eq)]
254#[non_exhaustive]
255pub enum ToolOutcome {
256    /// The tool ran and produced output normally.
257    Success,
258    /// The tool failed. Carries the structured [`ToolFailure`]; the model still
259    /// receives [`ToolExecutionResult::model_output`] as feedback.
260    Error(ToolFailure),
261    /// The tool body did not run because a
262    /// [`ToolCall`](crate::agent::StepEvent::ToolCall) hook returned
263    /// [`Flow::Skip`](crate::agent::Flow::Skip). This is a **framework** outcome
264    /// and includes approval-policy denials, which are expressed as `Flow::Skip`
265    /// (see the [type-level note](ToolOutcome#skipped-vs-denied)).
266    Skipped,
267    /// A **tool** declared the call denied by returning [`ToolReturn::denied`] /
268    /// [`ToolExecutionResult::denied`] — it ran its own check and refused. This is
269    /// **not** produced by a hook `Flow::Skip` (those are
270    /// [`Skipped`](Self::Skipped)); it is the tool-side counterpart to a skip (see
271    /// the [type-level note](ToolOutcome#skipped-vs-denied)).
272    Denied,
273}
274
275impl ToolOutcome {
276    /// A stable, machine-friendly identifier for the outcome, for tracing/metrics.
277    pub const fn as_str(&self) -> &'static str {
278        match self {
279            ToolOutcome::Success => "success",
280            ToolOutcome::Error(_) => "error",
281            ToolOutcome::Skipped => "skipped",
282            ToolOutcome::Denied => "denied",
283        }
284    }
285
286    /// Whether the tool ran successfully.
287    pub const fn is_success(&self) -> bool {
288        matches!(self, ToolOutcome::Success)
289    }
290
291    /// Whether the tool failed.
292    pub const fn is_error(&self) -> bool {
293        matches!(self, ToolOutcome::Error(_))
294    }
295
296    /// Whether a hook skipped the call (`Flow::Skip`) before execution — a
297    /// **framework** outcome that includes approval-policy denials (see the
298    /// [type-level note](ToolOutcome#skipped-vs-denied)).
299    pub const fn is_skipped(&self) -> bool {
300        matches!(self, ToolOutcome::Skipped)
301    }
302
303    /// Whether a **tool** refused the call (via [`ToolReturn::denied`]). Hook /
304    /// approval-policy `Flow::Skip` denials are [`Skipped`](Self::Skipped), not
305    /// `Denied` (see the [type-level note](ToolOutcome#skipped-vs-denied)).
306    pub const fn is_denied(&self) -> bool {
307        matches!(self, ToolOutcome::Denied)
308    }
309
310    /// The [`ToolFailure`] if this is an [`Error`](ToolOutcome::Error), else `None`.
311    pub const fn failure(&self) -> Option<&ToolFailure> {
312        match self {
313            ToolOutcome::Error(failure) => Some(failure),
314            _ => None,
315        }
316    }
317
318    /// The [`ToolFailureKind`] if this is an [`Error`](ToolOutcome::Error), else `None`.
319    pub const fn error_kind(&self) -> Option<ToolFailureKind> {
320        match self {
321            ToolOutcome::Error(failure) => Some(failure.kind),
322            _ => None,
323        }
324    }
325
326    /// Whether this is an [`Error`](ToolOutcome::Error) of exactly `kind`.
327    ///
328    /// The predicate a hook uses to react to a specific failure class:
329    ///
330    /// ```
331    /// use rig_core::tool::{ToolFailure, ToolFailureKind, ToolOutcome};
332    ///
333    /// let outcome = ToolOutcome::Error(ToolFailure::timeout("slow upstream"));
334    /// assert!(outcome.is_error_kind(ToolFailureKind::Timeout));
335    /// assert!(!outcome.is_error_kind(ToolFailureKind::NotFound));
336    /// ```
337    pub fn is_error_kind(&self, kind: ToolFailureKind) -> bool {
338        self.error_kind() == Some(kind)
339    }
340}
341
342/// The outcome a *tool* declares for a call it executed.
343///
344/// A strict subset of [`ToolOutcome`]: a tool that ran can report
345/// [`Success`](Self::Success), a handled [`Error`](Self::Error), or a
346/// [`Denied`](Self::Denied) refusal — but it **cannot** be
347/// [`Skipped`](ToolOutcome::Skipped). A skip is a *framework* decision made
348/// before the tool runs (a [`ToolCall`](crate::agent::StepEvent::ToolCall) hook
349/// returning [`Flow::Skip`](crate::agent::Flow::Skip)), so it has no
350/// tool-authored representation. Because [`ToolReturn`] carries this type — not
351/// [`ToolOutcome`] — it is *impossible* to construct a tool return (or a
352/// tool-built [`ToolExecutionResult`]) that claims to have been skipped while
353/// having actually run. Converts into the observed [`ToolOutcome`] via [`From`].
354#[derive(Debug, Clone, PartialEq, Eq)]
355#[non_exhaustive]
356pub enum ToolReturnOutcome {
357    /// The tool ran and produced output normally. Maps to [`ToolOutcome::Success`].
358    Success,
359    /// The tool ran and failed; carries the structured [`ToolFailure`]. The model
360    /// still receives the return's output as feedback. Maps to [`ToolOutcome::Error`].
361    Error(ToolFailure),
362    /// The tool ran its own check and refused the call (e.g. an internal
363    /// authorization check). Maps to [`ToolOutcome::Denied`].
364    Denied,
365}
366
367impl ToolReturnOutcome {
368    /// A stable, machine-friendly identifier, matching the [`ToolOutcome`] this
369    /// maps to (`"success"` / `"error"` / `"denied"`).
370    pub const fn as_str(&self) -> &'static str {
371        match self {
372            ToolReturnOutcome::Success => "success",
373            ToolReturnOutcome::Error(_) => "error",
374            ToolReturnOutcome::Denied => "denied",
375        }
376    }
377
378    /// The [`ToolFailure`] if this is an [`Error`](Self::Error), else `None`.
379    pub const fn failure(&self) -> Option<&ToolFailure> {
380        match self {
381            ToolReturnOutcome::Error(failure) => Some(failure),
382            _ => None,
383        }
384    }
385}
386
387impl From<ToolReturnOutcome> for ToolOutcome {
388    fn from(outcome: ToolReturnOutcome) -> Self {
389        match outcome {
390            ToolReturnOutcome::Success => ToolOutcome::Success,
391            ToolReturnOutcome::Error(failure) => ToolOutcome::Error(failure),
392            ToolReturnOutcome::Denied => ToolOutcome::Denied,
393        }
394    }
395}
396
397/// The full structured result of a single tool execution.
398///
399/// This is what the dynamic tool boundary ([`ToolDyn`](crate::tool::ToolDyn))
400/// produces and what flows through to the
401/// [`StepEvent::ToolResult`](crate::agent::StepEvent::ToolResult) hook event. It
402/// keeps the three concerns separate:
403///
404/// - [`model_output`](Self::model_output()): the text delivered to the model;
405/// - [`outcome`](Self::outcome()): the structured [`ToolOutcome`];
406/// - [`extensions`](Self::extensions()): metadata never sent to the model.
407///
408/// Tool authors rarely build this directly — they return a [`ToolReturn`] and the
409/// boundary assembles it. In a manual [`ToolDyn`](crate::tool::ToolDyn)
410/// implementation construct one with [`success`](Self::success) /
411/// [`failed`](Self::failed) / [`denied`](Self::denied); read it back with the
412/// [`model_output`](Self::model_output()) / [`outcome`](Self::outcome()) /
413/// [`extensions`](Self::extensions()) accessors.
414///
415/// The fields are crate-private on purpose: [`Skipped`](ToolOutcome::Skipped) is a
416/// framework-only outcome (a hook [`Flow::Skip`](crate::agent::Flow::Skip)), and
417/// there is no public constructor or setter that yields it — a tool that ran was
418/// not skipped. See the [`ToolOutcome` note](ToolOutcome#skipped-vs-denied).
419#[derive(Debug, Clone)]
420#[non_exhaustive]
421pub struct ToolExecutionResult {
422    pub(crate) model_output: String,
423    pub(crate) outcome: ToolOutcome,
424    pub(crate) extensions: ToolResultExtensions,
425}
426
427impl ToolExecutionResult {
428    /// Construct a result with the given model output and outcome, and no
429    /// extensions.
430    ///
431    /// Crate-internal because `outcome` is the full [`ToolOutcome`], including the
432    /// framework-only [`Skipped`](ToolOutcome::Skipped). Tool authors use
433    /// [`success`](Self::success) / [`failed`](Self::failed) /
434    /// [`denied`](Self::denied), which cannot produce `Skipped`.
435    pub(crate) fn new(model_output: impl Into<String>, outcome: ToolOutcome) -> Self {
436        Self {
437            model_output: model_output.into(),
438            outcome,
439            extensions: ToolResultExtensions::new(),
440        }
441    }
442
443    /// A successful result whose model output is `model_output` verbatim.
444    pub fn success(model_output: impl Into<String>) -> Self {
445        Self::new(model_output, ToolOutcome::Success)
446    }
447
448    /// A failed result: `model_output` is the model-visible feedback, `failure`
449    /// the structured classification.
450    pub fn failed(model_output: impl Into<String>, failure: ToolFailure) -> Self {
451        Self::new(model_output, ToolOutcome::Error(failure))
452    }
453
454    /// A [`Skipped`](ToolOutcome::Skipped) result (the body did not run).
455    ///
456    /// Framework-internal: `Skipped` is produced only when a `ToolCall` hook
457    /// returns [`Flow::Skip`](crate::agent::Flow::Skip). A tool author expresses
458    /// refusal with [`denied`](Self::denied) instead — see the
459    /// [`ToolOutcome` note](ToolOutcome#skipped-vs-denied).
460    pub(crate) fn skipped(model_output: impl Into<String>) -> Self {
461        Self::new(model_output, ToolOutcome::Skipped)
462    }
463
464    /// A [`Denied`](ToolOutcome::Denied) result: the tool refused the call (the
465    /// body did not run to completion). The tool-authored counterpart to a hook
466    /// [`Flow::Skip`](crate::agent::Flow::Skip); see the
467    /// [`ToolOutcome` note](ToolOutcome#skipped-vs-denied).
468    pub fn denied(model_output: impl Into<String>) -> Self {
469        Self::new(model_output, ToolOutcome::Denied)
470    }
471
472    /// Attach result extensions, replacing any already set.
473    pub fn with_extensions(mut self, extensions: ToolResultExtensions) -> Self {
474        self.extensions = extensions;
475        self
476    }
477
478    /// Insert a single value into the result extensions, returning the updated
479    /// result. The single-value counterpart to [`with_extensions`](Self::with_extensions),
480    /// mirroring [`ToolReturn::with_extension`] for manual
481    /// [`ToolDyn`](crate::tool::ToolDyn) implementations.
482    pub fn with_extension<
483        E: Clone + crate::wasm_compat::WasmCompatSend + crate::wasm_compat::WasmCompatSync + 'static,
484    >(
485        mut self,
486        extension: E,
487    ) -> Self {
488        self.extensions.insert(extension);
489        self
490    }
491
492    /// The text delivered to the model as the tool result. Present even for a
493    /// failure, so the model gets useful feedback (a handled error message).
494    pub fn model_output(&self) -> &str {
495        &self.model_output
496    }
497
498    /// The structured [`ToolOutcome`] of the call.
499    pub fn outcome(&self) -> &ToolOutcome {
500        &self.outcome
501    }
502
503    /// Metadata attached by the tool, surfaced to hooks/tracing but never sent
504    /// to the model.
505    pub fn extensions(&self) -> &ToolResultExtensions {
506        &self.extensions
507    }
508}
509
510/// A tool's return value carrying an optional structured outcome and metadata.
511///
512/// The ergonomic path for a tool author who wants more than a plain success. A
513/// tool's [`call`](crate::tool::Tool::call) still returns a bare
514/// `T: Serialize` for the common case; override
515/// [`Tool::call_structured`](crate::tool::Tool::call_structured) to return a
516/// `ToolReturn<T>` instead when you need to:
517///
518/// - attach [`extensions`](Self::extensions) (provider/application metadata) to a
519///   success;
520/// - report a *handled failure* that still shows structured output to the model
521///   ([`failed`](Self::failed));
522/// - mark the call [`denied`](Self::denied) — the tool ran its own check and
523///   refused (the tool-side counterpart to a hook `Flow::Skip`; there is no
524///   tool-authored *skipped* — that outcome is the framework's, see
525///   [`ToolOutcome`]).
526///
527/// The [`output`](Self::output) is serialized to the model exactly as a normal
528/// tool output would be (a `String` output stays verbatim; anything else becomes
529/// JSON), so switching a tool from `T` to `ToolReturn<T>` never changes what the
530/// model sees for the success case.
531///
532/// # Example
533/// ```
534/// use rig_core::tool::{ToolFailure, ToolReturn};
535///
536/// #[derive(Clone, Debug, PartialEq)]
537/// struct RequestId(String);
538///
539/// // A success that also records the upstream request id for telemetry.
540/// let ok: ToolReturn<String> =
541///     ToolReturn::success("42 results".to_string()).with_extension(RequestId("req-9".into()));
542///
543/// // A handled failure that still gives the model a structured message.
544/// let err: ToolReturn<String> =
545///     ToolReturn::failed("no such city".to_string(), ToolFailure::not_found("city=atlantis"));
546/// ```
547#[derive(Debug, Clone)]
548pub struct ToolReturn<T> {
549    /// The value serialized as the model-visible tool output.
550    pub output: T,
551    /// The structured outcome the tool declares. A [`ToolReturnOutcome`] (not a
552    /// [`ToolOutcome`]), so it can never be the framework-only
553    /// [`Skipped`](ToolOutcome::Skipped). Defaults to
554    /// [`ToolReturnOutcome::Success`].
555    pub outcome: ToolReturnOutcome,
556    /// Metadata surfaced to hooks/tracing but never sent to the model.
557    pub extensions: ToolResultExtensions,
558}
559
560impl<T> ToolReturn<T> {
561    /// A plain successful return wrapping `output`, with no extra metadata. This
562    /// is what the default [`Tool::call_structured`](crate::tool::Tool::call_structured)
563    /// produces from a bare [`Tool::call`](crate::tool::Tool::call) output.
564    pub fn success(output: T) -> Self {
565        Self {
566            output,
567            outcome: ToolReturnOutcome::Success,
568            extensions: ToolResultExtensions::new(),
569        }
570    }
571
572    /// A return wrapping `output` with an explicit [`ToolReturnOutcome`].
573    pub fn new(output: T, outcome: ToolReturnOutcome) -> Self {
574        Self {
575            output,
576            outcome,
577            extensions: ToolResultExtensions::new(),
578        }
579    }
580
581    /// A handled-failure return: `output` is still serialized to the model as
582    /// feedback, but the outcome is [`ToolReturnOutcome::Error`] carrying `failure`.
583    pub fn failed(output: T, failure: ToolFailure) -> Self {
584        Self::new(output, ToolReturnOutcome::Error(failure))
585    }
586
587    /// A [`denied`](ToolReturnOutcome::Denied) return: the tool ran its own check
588    /// and refused (the model still sees `output`). The tool-side counterpart to
589    /// a hook [`Flow::Skip`](crate::agent::Flow::Skip); there is no tool-authored
590    /// *skipped* — `Skipped` is a framework outcome (see
591    /// [`ToolReturnOutcome`](ToolReturnOutcome)).
592    pub fn denied(output: T) -> Self {
593        Self::new(output, ToolReturnOutcome::Denied)
594    }
595
596    /// Replace the outcome.
597    pub fn with_outcome(mut self, outcome: ToolReturnOutcome) -> Self {
598        self.outcome = outcome;
599        self
600    }
601
602    /// Replace the extensions wholesale.
603    pub fn with_extensions(mut self, extensions: ToolResultExtensions) -> Self {
604        self.extensions = extensions;
605        self
606    }
607
608    /// Insert a single value into the extensions, returning the updated return.
609    pub fn with_extension<
610        E: Clone + crate::wasm_compat::WasmCompatSend + crate::wasm_compat::WasmCompatSync + 'static,
611    >(
612        mut self,
613        extension: E,
614    ) -> Self {
615        self.extensions.insert(extension);
616        self
617    }
618}
619
620impl<T: Serialize> ToolReturn<T> {
621    /// Serialize `output` to the model-visible string and assemble a
622    /// [`ToolExecutionResult`], preserving the declared outcome and extensions.
623    ///
624    /// A `String` output is delivered verbatim; anything else is JSON-encoded —
625    /// the same shaping a bare tool output receives.
626    ///
627    /// If serialization fails, the tool's [`extensions`](Self::extensions) and its
628    /// declared *classification* are still preserved — a serialization failure is
629    /// a rendering problem, independent of whether the tool succeeded, failed, or
630    /// [`denied`](ToolReturnOutcome::Denied) the call — and only the `model_output`
631    /// falls back to a string explaining the error. The one exception is a declared
632    /// [`Success`](ToolReturnOutcome::Success): a success whose output cannot be
633    /// rendered *is* an internal fault, so it becomes an
634    /// [`Other`](ToolFailureKind::Other) failure.
635    pub(crate) fn into_execution_result(self) -> ToolExecutionResult {
636        let ToolReturn {
637            output,
638            outcome,
639            extensions,
640        } = self;
641        match super::serialize_tool_output(&output) {
642            Ok(model_output) => ToolExecutionResult {
643                model_output,
644                outcome: outcome.into(),
645                extensions,
646            },
647            Err(err) => {
648                let outcome = match outcome {
649                    // A success we cannot render is an internal serialization fault.
650                    ToolReturnOutcome::Success => {
651                        ToolOutcome::Error(ToolFailure::other(err.to_string()))
652                    }
653                    // A declared failure/denial keeps its classification.
654                    other => other.into(),
655                };
656                ToolExecutionResult {
657                    model_output: format!("failed to serialize tool output: {err}"),
658                    outcome,
659                    extensions,
660                }
661            }
662        }
663    }
664}
665
666// The structured result crosses `.await` points and is the output of the
667// `WasmBoxedFuture` returned by `ToolDyn::call_structured`, so on native targets
668// it must stay `Send + Sync`. This fails to compile if a future change (e.g. a
669// non-`Send` field on `ToolResultExtensions`) drops the property.
670#[cfg(not(target_family = "wasm"))]
671const _: fn() = || {
672    fn assert_send_sync<T: Send + Sync>() {}
673    assert_send_sync::<ToolExecutionResult>();
674    assert_send_sync::<ToolOutcome>();
675    assert_send_sync::<ToolFailure>();
676};
677
678#[cfg(test)]
679mod tests {
680    use super::*;
681
682    #[test]
683    fn per_kind_constructors_prefill_retryable() {
684        assert_eq!(ToolFailure::timeout("t").retryable, Some(true));
685        assert_eq!(ToolFailure::rate_limited("r").retryable, Some(true));
686        assert_eq!(ToolFailure::network("n").retryable, Some(true));
687        assert_eq!(ToolFailure::not_found("nf").retryable, Some(false));
688        assert_eq!(ToolFailure::permission_denied("p").retryable, Some(false));
689        assert_eq!(ToolFailure::invalid_args("i").retryable, Some(false));
690        assert_eq!(ToolFailure::cancelled("c").retryable, Some(false));
691        assert_eq!(ToolFailure::provider("p").retryable, None);
692        assert_eq!(ToolFailure::other("o").retryable, None);
693        // `new` leaves it unset regardless of kind.
694        assert_eq!(
695            ToolFailure::new(ToolFailureKind::Timeout, "t").retryable,
696            None
697        );
698    }
699
700    #[test]
701    fn failure_builders_compose() {
702        let failure = ToolFailure::rate_limited("slow down")
703            .with_http_status(429)
704            .with_code("RATE_LIMIT")
705            .with_retryable(false);
706        assert_eq!(failure.kind, ToolFailureKind::RateLimited);
707        assert_eq!(failure.http_status, Some(429));
708        assert_eq!(failure.code.as_deref(), Some("RATE_LIMIT"));
709        assert_eq!(failure.retryable, Some(false));
710        assert_eq!(failure.to_string(), "rate_limited: slow down");
711    }
712
713    #[test]
714    fn outcome_predicates() {
715        let ok = ToolOutcome::Success;
716        assert!(ok.is_success());
717        assert!(!ok.is_error());
718        assert_eq!(ok.error_kind(), None);
719        assert_eq!(ok.as_str(), "success");
720
721        let err = ToolOutcome::Error(ToolFailure::not_found("x"));
722        assert!(err.is_error());
723        assert!(err.is_error_kind(ToolFailureKind::NotFound));
724        assert!(!err.is_error_kind(ToolFailureKind::Timeout));
725        assert_eq!(err.error_kind(), Some(ToolFailureKind::NotFound));
726        assert_eq!(
727            err.failure().map(|f| f.kind),
728            Some(ToolFailureKind::NotFound)
729        );
730        assert_eq!(err.as_str(), "error");
731
732        assert!(ToolOutcome::Skipped.is_skipped());
733        assert!(ToolOutcome::Denied.is_denied());
734    }
735
736    #[test]
737    fn tool_return_success_serializes_verbatim_string() {
738        let result = ToolReturn::success("hello\nworld".to_string()).into_execution_result();
739        assert_eq!(result.model_output, "hello\nworld");
740        assert!(result.outcome.is_success());
741    }
742
743    #[test]
744    fn tool_return_object_serializes_as_json_and_keeps_outcome() {
745        let result = ToolReturn::failed(
746            serde_json::json!({ "status": "missing", "id": 7 }),
747            ToolFailure::not_found("id 7"),
748        )
749        .into_execution_result();
750        assert_eq!(
751            serde_json::from_str::<serde_json::Value>(&result.model_output).unwrap(),
752            serde_json::json!({ "status": "missing", "id": 7 })
753        );
754        assert!(result.outcome.is_error_kind(ToolFailureKind::NotFound));
755    }
756
757    #[test]
758    fn tool_return_extensions_flow_into_execution_result() {
759        #[derive(Clone, Debug, PartialEq)]
760        struct ReqId(String);
761
762        let result = ToolReturn::success(1u32)
763            .with_extension(ReqId("abc".into()))
764            .into_execution_result();
765        assert_eq!(result.extensions.get::<ReqId>(), Some(&ReqId("abc".into())));
766    }
767
768    #[test]
769    fn tool_return_outcome_maps_to_observed_outcome() {
770        // The tool-authorable set is exactly { Success, Error, Denied } — there is
771        // no `ToolReturnOutcome::Skipped` variant, so a tool return can never
772        // claim it was skipped (that guarantee is enforced at compile time; there
773        // is nothing to test at runtime). Each authorable outcome maps to its
774        // observed `ToolOutcome`:
775        assert_eq!(
776            ToolOutcome::from(ToolReturnOutcome::Success),
777            ToolOutcome::Success
778        );
779        assert_eq!(
780            ToolOutcome::from(ToolReturnOutcome::Denied),
781            ToolOutcome::Denied
782        );
783        let failure = ToolFailure::not_found("x");
784        assert_eq!(
785            ToolOutcome::from(ToolReturnOutcome::Error(failure.clone())),
786            ToolOutcome::Error(failure)
787        );
788
789        assert_eq!(ToolReturnOutcome::Success.as_str(), "success");
790        assert_eq!(ToolReturnOutcome::Denied.as_str(), "denied");
791        assert_eq!(
792            ToolReturnOutcome::Error(ToolFailure::timeout("t")).as_str(),
793            "error"
794        );
795        assert_eq!(
796            ToolReturnOutcome::Error(ToolFailure::timeout("t"))
797                .failure()
798                .map(|f| f.kind),
799            Some(ToolFailureKind::Timeout)
800        );
801        assert_eq!(ToolReturnOutcome::Denied.failure(), None);
802    }
803
804    #[test]
805    fn tool_return_denied_surfaces_as_denied_observed_outcome() {
806        let result = ToolReturn::denied("refused".to_string()).into_execution_result();
807        assert_eq!(*result.outcome(), ToolOutcome::Denied);
808        assert_eq!(result.model_output(), "refused");
809        assert!(result.outcome().is_denied());
810        assert!(!result.outcome().is_skipped());
811    }
812
813    #[test]
814    fn serialize_failure_preserves_declared_outcome_and_extensions() {
815        // An output whose `Serialize` impl always errors, so `into_execution_result`
816        // hits the fallback path.
817        struct Unserializable;
818        impl serde::Serialize for Unserializable {
819            fn serialize<S: serde::Serializer>(&self, _s: S) -> Result<S::Ok, S::Error> {
820                Err(serde::ser::Error::custom("cannot serialize"))
821            }
822        }
823
824        #[derive(Clone, Debug, PartialEq)]
825        struct ReqId(String);
826
827        // A declared *handled failure* keeps its classification and extensions;
828        // only `model_output` falls back to the serialization-error string. It must
829        // NOT be silently reclassified to `Error(Other)`.
830        let failed = ToolReturn::failed(Unserializable, ToolFailure::rate_limited("slow"))
831            .with_extension(ReqId("req-1".into()))
832            .into_execution_result();
833        assert!(
834            failed.outcome().is_error_kind(ToolFailureKind::RateLimited),
835            "declared failure classification must survive serialize failure; got {:?}",
836            failed.outcome()
837        );
838        assert_eq!(
839            failed.outcome().failure().and_then(|f| f.retryable),
840            Some(true),
841            "the declared failure's retryable hint must survive"
842        );
843        assert_eq!(
844            failed.extensions().get::<ReqId>(),
845            Some(&ReqId("req-1".into())),
846            "extensions must survive serialize failure"
847        );
848        assert!(failed.model_output().contains("failed to serialize"));
849
850        // A declared *denial* is preserved (not turned into an error).
851        let denied = ToolReturn::denied(Unserializable).into_execution_result();
852        assert!(
853            denied.outcome().is_denied(),
854            "declared denial must survive serialize failure; got {:?}",
855            denied.outcome()
856        );
857
858        // A declared *success* that cannot be rendered IS an internal fault.
859        let ok = ToolReturn::success(Unserializable).into_execution_result();
860        assert!(
861            ok.outcome().is_error_kind(ToolFailureKind::Other),
862            "a success whose output cannot serialize becomes Other; got {:?}",
863            ok.outcome()
864        );
865    }
866}