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