1use std::{error::Error, sync::Arc};
4
5use crate::{
6 tool::ToolOutput,
7 wasm_compat::{WasmCompatSend, WasmCompatSync},
8};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12#[non_exhaustive]
13pub enum ToolErrorKind {
14 InvalidArgs,
16 Timeout,
18 Cancelled,
20 NotFound,
22 PermissionDenied,
25 RateLimited,
27 Provider,
29 Network,
31 Other,
33}
34
35impl ToolErrorKind {
36 pub const fn as_str(self) -> &'static str {
38 match self {
39 Self::InvalidArgs => "invalid_args",
40 Self::Timeout => "timeout",
41 Self::Cancelled => "cancelled",
42 Self::NotFound => "not_found",
43 Self::PermissionDenied => "permission_denied",
44 Self::RateLimited => "rate_limited",
45 Self::Provider => "provider",
46 Self::Network => "network",
47 Self::Other => "other",
48 }
49 }
50
51 const fn default_retryable(self) -> Option<bool> {
52 match self {
53 Self::Timeout | Self::RateLimited | Self::Network => Some(true),
54 Self::InvalidArgs | Self::Cancelled | Self::NotFound | Self::PermissionDenied => {
55 Some(false)
56 }
57 Self::Provider | Self::Other => None,
58 }
59 }
60
61 const fn default_model_feedback(self) -> &'static str {
62 match self {
63 Self::InvalidArgs => "tool arguments were invalid",
64 Self::Timeout => "tool execution timed out",
65 Self::Cancelled => "tool execution was cancelled",
66 Self::NotFound => "the requested tool or resource was not found",
67 Self::PermissionDenied => "the tool denied the request",
68 Self::RateLimited => "the tool was rate limited; try again later",
69 Self::Provider => "the tool provider failed",
70 Self::Network => "the tool could not reach its upstream service",
71 Self::Other => "the tool failed",
72 }
73 }
74}
75
76impl std::fmt::Display for ToolErrorKind {
77 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78 f.write_str(self.as_str())
79 }
80}
81
82#[derive(Clone)]
92pub struct ToolExecutionError {
93 kind: ToolErrorKind,
94 message: String,
95 model_output: Box<ToolOutput>,
96 retryable: Option<bool>,
97 code: Option<String>,
98 http_status: Option<u16>,
99 refusal: bool,
100 #[cfg(not(target_family = "wasm"))]
101 source: Option<Arc<dyn Error + Send + Sync + 'static>>,
102 #[cfg(target_family = "wasm")]
103 source: Option<Arc<dyn Error + 'static>>,
104}
105
106impl ToolExecutionError {
107 pub fn new(kind: ToolErrorKind, message: impl Into<String>) -> Self {
109 let message = message.into();
110 Self {
111 kind,
112 model_output: Box::new(ToolOutput::text(message.clone())),
113 message,
114 retryable: kind.default_retryable(),
115 code: None,
116 http_status: None,
117 refusal: false,
118 source: None,
119 }
120 }
121
122 pub fn invalid_args(message: impl Into<String>) -> Self {
124 Self::new(ToolErrorKind::InvalidArgs, message)
125 }
126
127 pub fn timeout(message: impl Into<String>) -> Self {
129 Self::new(ToolErrorKind::Timeout, message)
130 }
131
132 pub fn cancelled(message: impl Into<String>) -> Self {
134 Self::new(ToolErrorKind::Cancelled, message)
135 }
136
137 pub fn not_found(message: impl Into<String>) -> Self {
139 Self::new(ToolErrorKind::NotFound, message)
140 }
141
142 pub fn permission_denied(message: impl Into<String>) -> Self {
148 Self::new(ToolErrorKind::PermissionDenied, message)
149 }
150
151 pub fn refused(message: impl Into<String>) -> Self {
156 let mut error = Self::new(ToolErrorKind::PermissionDenied, message);
157 error.refusal = true;
158 error
159 }
160
161 pub fn rate_limited(message: impl Into<String>) -> Self {
163 Self::new(ToolErrorKind::RateLimited, message)
164 }
165
166 pub fn provider(message: impl Into<String>) -> Self {
168 Self::new(ToolErrorKind::Provider, message)
169 }
170
171 pub fn network(message: impl Into<String>) -> Self {
173 Self::new(ToolErrorKind::Network, message)
174 }
175
176 pub fn other(message: impl Into<String>) -> Self {
178 Self::new(ToolErrorKind::Other, message)
179 }
180
181 pub fn from_error<E>(error: E) -> Self
188 where
189 E: Error + WasmCompatSend + WasmCompatSync + 'static,
190 {
191 #[cfg(not(target_family = "wasm"))]
192 {
193 let source: Box<dyn Error + Send + Sync + 'static> = Box::new(error);
194 return match source.downcast::<Self>() {
195 Ok(error) => *error,
196 Err(source) => {
197 let message = source.to_string();
198 let mut error = Self::other(message).redact_model_feedback();
199 error.source = Some(Arc::from(source));
200 error
201 }
202 };
203 }
204 #[cfg(target_family = "wasm")]
205 {
206 let source: Box<dyn Error + 'static> = Box::new(error);
207 match source.downcast::<Self>() {
208 Ok(error) => *error,
209 Err(source) => {
210 let message = source.to_string();
211 let mut error = Self::other(message).redact_model_feedback();
212 error.source = Some(Arc::from(source));
213 error
214 }
215 }
216 }
217 }
218
219 pub fn with_model_feedback(mut self, feedback: impl Into<String>) -> Self {
221 self.model_output = Box::new(ToolOutput::text(feedback));
222 self
223 }
224
225 pub fn with_model_output(mut self, output: ToolOutput) -> Self {
228 self.model_output = Box::new(output);
229 self
230 }
231
232 pub fn redact_model_feedback(mut self) -> Self {
240 self.model_output = Box::new(ToolOutput::text(self.kind.default_model_feedback()));
241 self
242 }
243
244 pub fn with_retryable(mut self, retryable: bool) -> Self {
246 self.retryable = Some(retryable);
247 self
248 }
249
250 pub fn with_code(mut self, code: impl Into<String>) -> Self {
252 self.code = Some(code.into());
253 self
254 }
255
256 pub fn with_http_status(mut self, status: u16) -> Self {
258 self.http_status = Some(status);
259 self
260 }
261
262 pub fn with_source<E>(mut self, source: E) -> Self
264 where
265 E: Error + WasmCompatSend + WasmCompatSync + 'static,
266 {
267 self.source = Some(Arc::new(source));
268 self
269 }
270
271 pub const fn kind(&self) -> ToolErrorKind {
273 self.kind
274 }
275
276 pub fn message(&self) -> &str {
278 &self.message
279 }
280
281 pub fn model_feedback(&self) -> Option<&str> {
286 self.model_output.as_text()
287 }
288
289 pub fn model_output(&self) -> &ToolOutput {
291 &self.model_output
292 }
293
294 pub const fn retryable(&self) -> Option<bool> {
296 self.retryable
297 }
298
299 pub fn code(&self) -> Option<&str> {
301 self.code.as_deref()
302 }
303
304 pub const fn http_status(&self) -> Option<u16> {
306 self.http_status
307 }
308
309 pub const fn is_refusal(&self) -> bool {
311 self.refusal
312 }
313
314 pub fn downcast_ref<E>(&self) -> Option<&E>
316 where
317 E: Error + WasmCompatSend + WasmCompatSync + 'static,
318 {
319 self.source.as_ref()?.downcast_ref::<E>()
320 }
321
322 pub fn is<E>(&self) -> bool
324 where
325 E: Error + WasmCompatSend + WasmCompatSync + 'static,
326 {
327 self.downcast_ref::<E>().is_some()
328 }
329}
330
331impl std::fmt::Display for ToolExecutionError {
332 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
333 f.write_str(&self.message)
334 }
335}
336
337impl std::fmt::Debug for ToolExecutionError {
338 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
339 f.debug_struct("ToolExecutionError")
340 .field("kind", &self.kind)
341 .field("retryable", &self.retryable)
342 .field("code", &self.code)
343 .field("http_status", &self.http_status)
344 .field("refusal", &self.refusal)
345 .field("model_output", &"<redacted>")
346 .field("source_configured", &self.source.is_some())
347 .finish()
348 }
349}
350
351impl Error for ToolExecutionError {
352 fn source(&self) -> Option<&(dyn Error + 'static)> {
353 self.source
354 .as_deref()
355 .map(|source| source as &(dyn Error + 'static))
356 }
357}
358
359#[derive(Clone)]
361enum ToolDisposition {
362 Success(ToolOutput),
363 Error(ToolExecutionError),
364 Refused(ToolExecutionError),
365 Skipped(ToolOutput),
366}
367
368#[derive(Clone)]
374pub struct ToolResult {
375 disposition: ToolDisposition,
376}
377
378impl std::fmt::Debug for ToolResult {
379 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
380 let error = match &self.disposition {
381 ToolDisposition::Error(error) | ToolDisposition::Refused(error) => Some(error),
382 ToolDisposition::Success(_) | ToolDisposition::Skipped(_) => None,
383 };
384 formatter
385 .debug_struct("ToolResult")
386 .field("status", &self.status_name())
387 .field("error_kind", &error.map(ToolExecutionError::kind))
388 .field("retryable", &error.and_then(ToolExecutionError::retryable))
389 .field("code", &error.and_then(ToolExecutionError::code))
390 .field(
391 "http_status",
392 &error.and_then(ToolExecutionError::http_status),
393 )
394 .finish()
395 }
396}
397
398impl ToolResult {
399 pub fn success(output: ToolOutput) -> Self {
401 Self {
402 disposition: ToolDisposition::Success(output),
403 }
404 }
405
406 pub fn failed(error: ToolExecutionError) -> Self {
408 let disposition = if error.is_refusal() {
409 ToolDisposition::Refused(error)
410 } else {
411 ToolDisposition::Error(error)
412 };
413 Self { disposition }
414 }
415
416 pub fn skipped(reason: impl Into<String>) -> Self {
418 Self {
419 disposition: ToolDisposition::Skipped(ToolOutput::text(reason)),
420 }
421 }
422
423 pub fn output(&self) -> &ToolOutput {
425 match &self.disposition {
426 ToolDisposition::Success(output) | ToolDisposition::Skipped(output) => output,
427 ToolDisposition::Error(error) | ToolDisposition::Refused(error) => error.model_output(),
428 }
429 }
430
431 pub fn error(&self) -> Option<&ToolExecutionError> {
435 match &self.disposition {
436 ToolDisposition::Error(error) => Some(error),
437 ToolDisposition::Success(_)
438 | ToolDisposition::Refused(_)
439 | ToolDisposition::Skipped(_) => None,
440 }
441 }
442
443 pub fn refusal(&self) -> Option<&ToolExecutionError> {
447 match &self.disposition {
448 ToolDisposition::Refused(error) => Some(error),
449 ToolDisposition::Success(_)
450 | ToolDisposition::Error(_)
451 | ToolDisposition::Skipped(_) => None,
452 }
453 }
454
455 pub fn is_success(&self) -> bool {
457 matches!(&self.disposition, ToolDisposition::Success(_))
458 }
459
460 pub fn is_error(&self) -> bool {
465 matches!(&self.disposition, ToolDisposition::Error(_))
466 }
467
468 pub fn is_skipped(&self) -> bool {
470 matches!(&self.disposition, ToolDisposition::Skipped(_))
471 }
472
473 pub fn is_refused(&self) -> bool {
475 matches!(&self.disposition, ToolDisposition::Refused(_))
476 }
477
478 pub fn is_error_kind(&self, kind: ToolErrorKind) -> bool {
483 self.error().is_some_and(|error| error.kind == kind)
484 }
485
486 pub fn status_name(&self) -> &'static str {
488 match &self.disposition {
489 ToolDisposition::Success(_) => "success",
490 ToolDisposition::Error(_) => "error",
491 ToolDisposition::Refused(_) => "denied",
492 ToolDisposition::Skipped(_) => "skipped",
493 }
494 }
495}
496
497#[cfg(not(target_family = "wasm"))]
498const _: fn() = || {
499 fn assert_send_sync<T: Send + Sync>() {}
500 assert_send_sync::<ToolExecutionError>();
501 assert_send_sync::<ToolResult>();
502};
503
504#[cfg(test)]
505mod tests {
506 use super::*;
507
508 #[derive(Debug, thiserror::Error)]
509 #[error("secret detail")]
510 struct Concrete;
511
512 #[test]
513 fn envelope_is_classified_cloneable_downcastable_and_redacted() {
514 let error = ToolExecutionError::provider("operator message")
515 .with_model_feedback("safe feedback")
516 .with_http_status(503)
517 .with_source(Concrete);
518 let cloned = error.clone();
519 assert_eq!(error.kind(), ToolErrorKind::Provider);
520 assert_eq!(error.model_feedback(), Some("safe feedback"));
521 assert_eq!(error.http_status(), Some(503));
522 assert!(cloned.is::<Concrete>());
523 assert!(!format!("{error:?}").contains("secret detail"));
524 }
525
526 #[test]
527 fn converting_an_existing_envelope_preserves_classification() {
528 let error = ToolExecutionError::from_error(ToolExecutionError::timeout("slow"));
529 assert_eq!(error.kind(), ToolErrorKind::Timeout);
530 assert_eq!(error.retryable(), Some(true));
531 }
532
533 #[test]
534 fn detailed_diagnostics_are_model_visible_by_default() {
535 let error = ToolExecutionError::provider("upstream rejected field `region`");
536 let result = ToolResult::failed(error.clone());
537
538 assert_eq!(error.message(), "upstream rejected field `region`");
539 assert_eq!(
540 error.model_feedback(),
541 Some("upstream rejected field `region`")
542 );
543 assert_eq!(
544 result.output().as_text(),
545 Some("upstream rejected field `region`")
546 );
547 }
548
549 #[test]
550 fn sensitive_diagnostics_can_be_explicitly_redacted() {
551 let error = ToolExecutionError::provider("authorization header Bearer secret-token")
552 .redact_model_feedback();
553 let result = ToolResult::failed(error.clone());
554
555 assert_eq!(error.message(), "authorization header Bearer secret-token");
556 assert_eq!(error.model_feedback(), Some("the tool provider failed"));
557 assert_eq!(result.output().as_text(), Some("the tool provider failed"));
558 assert!(!result.output().render().contains("secret-token"));
559 }
560
561 #[test]
562 fn errors_can_expose_structured_model_output() {
563 let output = ToolOutput::json(serde_json::json!({
564 "error": "invalid region",
565 "allowed": ["us", "eu"]
566 }));
567 let result = ToolResult::failed(
568 ToolExecutionError::invalid_args("region was invalid")
569 .with_model_output(output.clone()),
570 );
571
572 assert_eq!(result.output(), &output);
573 assert_eq!(result.error().unwrap().model_output(), &output);
574 assert_eq!(result.error().unwrap().model_feedback(), None);
575 }
576
577 #[test]
578 fn skip_refusal_and_permission_failure_are_distinct() {
579 let skipped = ToolResult::skipped("policy");
580 let refused = ToolResult::failed(ToolExecutionError::refused("tool refused"));
581 let permission_failure = ToolResult::failed(ToolExecutionError::permission_denied(
582 "authorization failed",
583 ));
584 assert!(skipped.is_skipped());
585 assert!(!skipped.is_refused());
586 assert!(refused.is_refused());
587 assert!(!refused.is_skipped());
588 assert!(!refused.is_error());
589 assert!(refused.error().is_none());
590 assert!(refused.refusal().is_some_and(|error| error.is_refusal()));
591 assert!(permission_failure.is_error());
592 assert!(!permission_failure.is_refused());
593 assert!(permission_failure.refusal().is_none());
594 assert!(permission_failure.is_error_kind(ToolErrorKind::PermissionDenied));
595 assert!(!refused.is_error_kind(ToolErrorKind::PermissionDenied));
596 assert_eq!(refused.status_name(), "denied");
597 assert_eq!(permission_failure.status_name(), "error");
598 }
599
600 #[test]
601 fn execution_error_debug_redacts_operator_and_model_payloads() {
602 let error = ToolExecutionError::provider("Bearer secret-operator-message")
603 .with_model_output(ToolOutput::json(serde_json::json!({
604 "credential": "secret-model-output"
605 })))
606 .with_source(Concrete);
607
608 let debug = format!("{error:?}");
609 assert!(debug.contains("kind: Provider"));
610 assert!(debug.contains("model_output: \"<redacted>\""));
611 assert!(debug.contains("source_configured: true"));
612 for secret in [
613 "secret-operator-message",
614 "secret-model-output",
615 "secret detail",
616 ] {
617 assert!(!debug.contains(secret));
618 }
619 }
620
621 #[test]
622 fn debug_redacts_every_tool_result_disposition() {
623 let success = ToolResult::success(ToolOutput::text("secret-success"));
624 let failure = ToolResult::failed(
625 ToolExecutionError::provider("secret-operator").with_model_feedback("secret-model"),
626 );
627 let skipped = ToolResult::skipped("secret-skip");
628 let refused = ToolResult::failed(ToolExecutionError::refused("secret-refusal"));
629
630 for (result, expected_status) in [
631 (success, "success"),
632 (failure, "error"),
633 (skipped, "skipped"),
634 (refused, "denied"),
635 ] {
636 let debug = format!("{result:?}");
637 assert!(debug.contains(expected_status));
638 for secret in [
639 "secret-success",
640 "secret-operator",
641 "secret-model",
642 "secret-skip",
643 "secret-refusal",
644 ] {
645 assert!(!debug.contains(secret));
646 }
647 }
648 }
649}
650
651#[cfg(test)]
652mod migrated_tests {
653 use super::*;
654
655 #[test]
656 fn per_kind_constructors_set_default_retryability() {
657 for (error, retryable) in [
658 (ToolExecutionError::timeout("t"), Some(true)),
659 (ToolExecutionError::rate_limited("r"), Some(true)),
660 (ToolExecutionError::network("n"), Some(true)),
661 (ToolExecutionError::not_found("nf"), Some(false)),
662 (ToolExecutionError::permission_denied("p"), Some(false)),
663 (ToolExecutionError::invalid_args("i"), Some(false)),
664 (ToolExecutionError::cancelled("c"), Some(false)),
665 (ToolExecutionError::provider("p"), None),
666 (ToolExecutionError::other("o"), None),
667 ] {
668 assert_eq!(error.retryable(), retryable);
669 }
670 }
671
672 #[test]
673 fn error_builder_preserves_policy_fields_and_feedback() {
674 let error = ToolExecutionError::rate_limited("operator")
675 .with_model_feedback("slow down")
676 .with_retryable(false)
677 .with_code("RATE_42")
678 .with_http_status(429);
679 assert_eq!(error.kind(), ToolErrorKind::RateLimited);
680 assert_eq!(error.message(), "operator");
681 assert_eq!(error.model_feedback(), Some("slow down"));
682 assert_eq!(error.retryable(), Some(false));
683 assert_eq!(error.code(), Some("RATE_42"));
684 assert_eq!(error.http_status(), Some(429));
685 let result = ToolResult::failed(error);
686 assert_eq!(result.output().as_text(), Some("slow down"));
687 assert!(result.is_error_kind(ToolErrorKind::RateLimited));
688 }
689
690 #[test]
691 fn success_preserves_multiline_output_verbatim() {
692 let result = ToolResult::success(ToolOutput::text("hello\nworld"));
693 assert!(result.is_success());
694 assert_eq!(result.output().as_text(), Some("hello\nworld"));
695 assert!(result.error().is_none());
696 }
697
698 #[test]
699 fn result_states_are_mutually_distinguishable() {
700 let success = ToolResult::success(ToolOutput::text("ok"));
701 let failure = ToolResult::failed(ToolExecutionError::not_found("missing"));
702 let skipped = ToolResult::skipped("policy");
703 let refused = ToolResult::failed(ToolExecutionError::refused("denied"));
704 assert!(success.is_success());
705 assert!(failure.is_error());
706 assert!(skipped.is_skipped());
707 assert!(refused.is_refused());
708 assert!(!refused.is_error());
709 assert!(!skipped.is_refused());
710 assert!(!refused.is_skipped());
711 assert_eq!(success.status_name(), "success");
712 assert_eq!(failure.status_name(), "error");
713 assert_eq!(skipped.status_name(), "skipped");
714 assert_eq!(refused.status_name(), "denied");
715 }
716
717 #[test]
718 fn from_error_keeps_existing_envelope_and_wraps_other_sources() {
719 #[derive(Debug, thiserror::Error)]
720 #[error("boom")]
721 struct Boom;
722 let existing = ToolExecutionError::timeout("slow").with_code("T");
723 let kept = ToolExecutionError::from_error(existing);
724 assert_eq!(kept.kind(), ToolErrorKind::Timeout);
725 assert_eq!(kept.code(), Some("T"));
726 let wrapped = ToolExecutionError::from_error(Boom);
727 assert_eq!(wrapped.kind(), ToolErrorKind::Other);
728 assert!(wrapped.is::<Boom>());
729 assert_eq!(wrapped.message(), "boom");
730 assert_eq!(wrapped.model_feedback(), Some("the tool failed"));
731 }
732
733 #[test]
734 fn from_error_preserves_refusal_disposition() {
735 let refused = ToolExecutionError::from_error(
736 ToolExecutionError::refused("declined").with_code("POLICY"),
737 );
738 assert!(refused.is_refusal());
739 assert_eq!(refused.kind(), ToolErrorKind::PermissionDenied);
740 assert_eq!(refused.code(), Some("POLICY"));
741 }
742}