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}