1use crate::executor::ErrorKind;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize)]
17pub enum ToolErrorCategory {
18 ToolNotFound,
21
22 InvalidParameters,
25 TypeMismatch,
27
28 PolicyBlocked,
31 ConfirmationRequired,
33
34 PermanentFailure,
37 Cancelled,
39
40 RateLimited,
43 ServerError,
45 NetworkError,
47 Timeout,
49}
50
51impl ToolErrorCategory {
52 #[must_use]
54 pub fn is_retryable(self) -> bool {
55 matches!(
56 self,
57 Self::RateLimited | Self::ServerError | Self::NetworkError | Self::Timeout
58 )
59 }
60
61 #[must_use]
66 pub fn needs_parameter_reformat(self) -> bool {
67 matches!(self, Self::InvalidParameters | Self::TypeMismatch)
68 }
69
70 #[must_use]
77 pub fn is_quality_failure(self) -> bool {
78 matches!(
79 self,
80 Self::InvalidParameters | Self::TypeMismatch | Self::ToolNotFound
81 )
82 }
83
84 #[must_use]
86 pub fn error_kind(self) -> ErrorKind {
87 if self.is_retryable() {
88 ErrorKind::Transient
89 } else {
90 ErrorKind::Permanent
91 }
92 }
93
94 #[must_use]
96 pub fn label(self) -> &'static str {
97 match self {
98 Self::ToolNotFound => "tool_not_found",
99 Self::InvalidParameters => "invalid_parameters",
100 Self::TypeMismatch => "type_mismatch",
101 Self::PolicyBlocked => "policy_blocked",
102 Self::ConfirmationRequired => "confirmation_required",
103 Self::PermanentFailure => "permanent_failure",
104 Self::Cancelled => "cancelled",
105 Self::RateLimited => "rate_limited",
106 Self::ServerError => "server_error",
107 Self::NetworkError => "network_error",
108 Self::Timeout => "timeout",
109 }
110 }
111
112 #[must_use]
114 pub fn suggestion(self) -> &'static str {
115 match self {
116 Self::ToolNotFound => {
117 "Check the tool name. Use tool_definitions to see available tools."
118 }
119 Self::InvalidParameters => "Review the tool schema and provide correct parameters.",
120 Self::TypeMismatch => "Check parameter types against the tool schema.",
121 Self::PolicyBlocked => {
122 "This operation is blocked by security policy. Try an alternative approach."
123 }
124 Self::ConfirmationRequired => "This operation requires user confirmation.",
125 Self::PermanentFailure => {
126 "This resource is not available. Try an alternative approach."
127 }
128 Self::Cancelled => "Operation was cancelled by the user.",
129 Self::RateLimited => "Rate limit exceeded. The system will retry if possible.",
130 Self::ServerError => "Server error. The system will retry if possible.",
131 Self::NetworkError => "Network error. The system will retry if possible.",
132 Self::Timeout => "Operation timed out. The system will retry if possible.",
133 }
134 }
135}
136
137#[derive(Debug, Clone, serde::Serialize)]
142pub struct ToolErrorFeedback {
143 pub category: ToolErrorCategory,
144 pub message: String,
145 pub retryable: bool,
146}
147
148impl ToolErrorFeedback {
149 #[must_use]
151 pub fn format_for_llm(&self) -> String {
152 format!(
153 "[tool_error]\ncategory: {}\nerror: {}\nsuggestion: {}\nretryable: {}",
154 self.category.label(),
155 self.message,
156 self.category.suggestion(),
157 self.retryable,
158 )
159 }
160}
161
162#[must_use]
164pub fn classify_http_status(status: u16) -> ToolErrorCategory {
165 match status {
166 400 | 422 => ToolErrorCategory::InvalidParameters,
167 401 | 403 => ToolErrorCategory::PolicyBlocked,
168 429 => ToolErrorCategory::RateLimited,
169 500..=599 => ToolErrorCategory::ServerError,
170 _ => ToolErrorCategory::PermanentFailure,
172 }
173}
174
175#[must_use]
184pub fn classify_io_error(err: &std::io::Error) -> ToolErrorCategory {
185 match err.kind() {
186 std::io::ErrorKind::TimedOut => ToolErrorCategory::Timeout,
187 std::io::ErrorKind::ConnectionRefused
188 | std::io::ErrorKind::ConnectionReset
189 | std::io::ErrorKind::ConnectionAborted
190 | std::io::ErrorKind::BrokenPipe => ToolErrorCategory::NetworkError,
191 std::io::ErrorKind::WouldBlock | std::io::ErrorKind::Interrupted => {
195 ToolErrorCategory::ServerError
196 }
197 std::io::ErrorKind::PermissionDenied => ToolErrorCategory::PolicyBlocked,
198 _ => ToolErrorCategory::PermanentFailure,
201 }
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 #[test]
209 fn retryable_categories() {
210 assert!(ToolErrorCategory::RateLimited.is_retryable());
211 assert!(ToolErrorCategory::ServerError.is_retryable());
212 assert!(ToolErrorCategory::NetworkError.is_retryable());
213 assert!(ToolErrorCategory::Timeout.is_retryable());
214
215 assert!(!ToolErrorCategory::InvalidParameters.is_retryable());
216 assert!(!ToolErrorCategory::TypeMismatch.is_retryable());
217 assert!(!ToolErrorCategory::ToolNotFound.is_retryable());
218 assert!(!ToolErrorCategory::PolicyBlocked.is_retryable());
219 assert!(!ToolErrorCategory::PermanentFailure.is_retryable());
220 assert!(!ToolErrorCategory::Cancelled.is_retryable());
221 assert!(!ToolErrorCategory::ConfirmationRequired.is_retryable());
222 }
223
224 #[test]
225 fn quality_failure_categories() {
226 assert!(ToolErrorCategory::InvalidParameters.is_quality_failure());
227 assert!(ToolErrorCategory::TypeMismatch.is_quality_failure());
228 assert!(ToolErrorCategory::ToolNotFound.is_quality_failure());
229
230 assert!(!ToolErrorCategory::NetworkError.is_quality_failure());
233 assert!(!ToolErrorCategory::ServerError.is_quality_failure());
234 assert!(!ToolErrorCategory::RateLimited.is_quality_failure());
235 assert!(!ToolErrorCategory::Timeout.is_quality_failure());
236 assert!(!ToolErrorCategory::PolicyBlocked.is_quality_failure());
237 assert!(!ToolErrorCategory::PermanentFailure.is_quality_failure());
238 assert!(!ToolErrorCategory::Cancelled.is_quality_failure());
239 }
240
241 #[test]
242 fn needs_parameter_reformat() {
243 assert!(ToolErrorCategory::InvalidParameters.needs_parameter_reformat());
244 assert!(ToolErrorCategory::TypeMismatch.needs_parameter_reformat());
245 assert!(!ToolErrorCategory::NetworkError.needs_parameter_reformat());
246 assert!(!ToolErrorCategory::ToolNotFound.needs_parameter_reformat());
247 }
248
249 #[test]
250 fn error_kind_backward_compat() {
251 assert_eq!(
253 ToolErrorCategory::NetworkError.error_kind(),
254 ErrorKind::Transient
255 );
256 assert_eq!(
257 ToolErrorCategory::Timeout.error_kind(),
258 ErrorKind::Transient
259 );
260 assert_eq!(
262 ToolErrorCategory::InvalidParameters.error_kind(),
263 ErrorKind::Permanent
264 );
265 assert_eq!(
266 ToolErrorCategory::PolicyBlocked.error_kind(),
267 ErrorKind::Permanent
268 );
269 }
270
271 #[test]
272 fn classify_http_status_codes() {
273 assert_eq!(classify_http_status(403), ToolErrorCategory::PolicyBlocked);
274 assert_eq!(
275 classify_http_status(404),
276 ToolErrorCategory::PermanentFailure
277 );
278 assert_eq!(
279 classify_http_status(422),
280 ToolErrorCategory::InvalidParameters
281 );
282 assert_eq!(classify_http_status(429), ToolErrorCategory::RateLimited);
283 assert_eq!(classify_http_status(500), ToolErrorCategory::ServerError);
284 assert_eq!(classify_http_status(503), ToolErrorCategory::ServerError);
285 assert_eq!(
286 classify_http_status(200),
287 ToolErrorCategory::PermanentFailure
288 );
289 }
290
291 #[test]
292 fn classify_io_not_found_is_permanent_not_tool_not_found() {
293 let err = std::io::Error::new(std::io::ErrorKind::NotFound, "No such file or directory");
296 assert_eq!(classify_io_error(&err), ToolErrorCategory::PermanentFailure);
297 }
298
299 #[test]
300 fn classify_io_connection_errors() {
301 let refused =
302 std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "connection refused");
303 assert_eq!(classify_io_error(&refused), ToolErrorCategory::NetworkError);
304
305 let reset = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "reset");
306 assert_eq!(classify_io_error(&reset), ToolErrorCategory::NetworkError);
307
308 let timed_out = std::io::Error::new(std::io::ErrorKind::TimedOut, "timed out");
309 assert_eq!(classify_io_error(&timed_out), ToolErrorCategory::Timeout);
310 }
311
312 #[test]
313 fn tool_error_feedback_format() {
314 let fb = ToolErrorFeedback {
315 category: ToolErrorCategory::InvalidParameters,
316 message: "missing required field: url".to_owned(),
317 retryable: false,
318 };
319 let s = fb.format_for_llm();
320 assert!(s.contains("[tool_error]"));
321 assert!(s.contains("invalid_parameters"));
322 assert!(s.contains("missing required field: url"));
323 assert!(s.contains("retryable: false"));
324 }
325
326 #[test]
327 fn all_categories_have_labels() {
328 let categories = [
329 ToolErrorCategory::ToolNotFound,
330 ToolErrorCategory::InvalidParameters,
331 ToolErrorCategory::TypeMismatch,
332 ToolErrorCategory::PolicyBlocked,
333 ToolErrorCategory::ConfirmationRequired,
334 ToolErrorCategory::PermanentFailure,
335 ToolErrorCategory::Cancelled,
336 ToolErrorCategory::RateLimited,
337 ToolErrorCategory::ServerError,
338 ToolErrorCategory::NetworkError,
339 ToolErrorCategory::Timeout,
340 ];
341 for cat in categories {
342 assert!(!cat.label().is_empty(), "category {cat:?} has empty label");
343 assert!(
344 !cat.suggestion().is_empty(),
345 "category {cat:?} has empty suggestion"
346 );
347 }
348 }
349
350 #[test]
353 fn classify_http_400_is_invalid_parameters() {
354 assert_eq!(
355 classify_http_status(400),
356 ToolErrorCategory::InvalidParameters
357 );
358 }
359
360 #[test]
361 fn classify_http_401_is_policy_blocked() {
362 assert_eq!(classify_http_status(401), ToolErrorCategory::PolicyBlocked);
363 }
364
365 #[test]
366 fn classify_http_502_is_server_error() {
367 assert_eq!(classify_http_status(502), ToolErrorCategory::ServerError);
368 }
369
370 #[test]
373 fn feedback_permanent_failure_not_retryable() {
374 let fb = ToolErrorFeedback {
375 category: ToolErrorCategory::PermanentFailure,
376 message: "resource does not exist".to_owned(),
377 retryable: false,
378 };
379 let s = fb.format_for_llm();
380 assert!(s.contains("permanent_failure"));
381 assert!(s.contains("resource does not exist"));
382 assert!(s.contains("retryable: false"));
383 let suggestion = ToolErrorCategory::PermanentFailure.suggestion();
385 assert!(!suggestion.contains("retry automatically"), "{suggestion}");
386 }
387
388 #[test]
389 fn feedback_rate_limited_is_retryable_and_mentions_retry() {
390 let fb = ToolErrorFeedback {
391 category: ToolErrorCategory::RateLimited,
392 message: "too many requests".to_owned(),
393 retryable: true,
394 };
395 let s = fb.format_for_llm();
396 assert!(s.contains("rate_limited"));
397 assert!(s.contains("retryable: true"));
398 let suggestion = ToolErrorCategory::RateLimited.suggestion();
400 assert!(suggestion.contains("retry"), "{suggestion}");
401 assert!(!suggestion.contains("automatically"), "{suggestion}");
402 }
403
404 #[test]
405 fn transient_suggestion_neutral_no_automatically() {
406 for cat in [
409 ToolErrorCategory::ServerError,
410 ToolErrorCategory::NetworkError,
411 ToolErrorCategory::RateLimited,
412 ToolErrorCategory::Timeout,
413 ] {
414 let s = cat.suggestion();
415 assert!(
416 !s.contains("automatically"),
417 "{cat:?} suggestion must not promise automatic retry: {s}"
418 );
419 }
420 }
421
422 #[test]
423 fn feedback_retryable_matches_category_is_retryable() {
424 for cat in [
426 ToolErrorCategory::ServerError,
427 ToolErrorCategory::NetworkError,
428 ToolErrorCategory::RateLimited,
429 ToolErrorCategory::Timeout,
430 ] {
431 let fb = ToolErrorFeedback {
432 category: cat,
433 message: "error".to_owned(),
434 retryable: cat.is_retryable(),
435 };
436 assert!(fb.retryable, "{cat:?} feedback must be retryable");
437 }
438
439 for cat in [
441 ToolErrorCategory::InvalidParameters,
442 ToolErrorCategory::PolicyBlocked,
443 ToolErrorCategory::PermanentFailure,
444 ] {
445 let fb = ToolErrorFeedback {
446 category: cat,
447 message: "error".to_owned(),
448 retryable: cat.is_retryable(),
449 };
450 assert!(!fb.retryable, "{cat:?} feedback must not be retryable");
451 }
452 }
453
454 #[test]
457 fn b4_infrastructure_errors_not_quality_failures() {
458 for cat in [
460 ToolErrorCategory::NetworkError,
461 ToolErrorCategory::ServerError,
462 ToolErrorCategory::RateLimited,
463 ToolErrorCategory::Timeout,
464 ] {
465 assert!(
466 !cat.is_quality_failure(),
467 "{cat:?} must not be a quality failure"
468 );
469 assert!(cat.is_retryable(), "{cat:?} must be retryable");
471 }
472 }
473
474 #[test]
475 fn b4_quality_failures_may_trigger_reflection() {
476 for cat in [
478 ToolErrorCategory::InvalidParameters,
479 ToolErrorCategory::TypeMismatch,
480 ToolErrorCategory::ToolNotFound,
481 ] {
482 assert!(
483 cat.is_quality_failure(),
484 "{cat:?} must be a quality failure"
485 );
486 assert!(!cat.is_retryable(), "{cat:?} must not be retryable");
488 }
489 }
490
491 #[test]
494 fn b2_io_not_found_maps_to_permanent_failure_not_tool_not_found() {
495 let err = std::io::Error::new(std::io::ErrorKind::NotFound, "bash: command not found");
496 let cat = classify_io_error(&err);
497 assert_ne!(
498 cat,
499 ToolErrorCategory::ToolNotFound,
500 "OS-level NotFound must NOT map to ToolNotFound"
501 );
502 assert_eq!(
503 cat,
504 ToolErrorCategory::PermanentFailure,
505 "OS-level NotFound must map to PermanentFailure"
506 );
507 }
508
509 #[test]
512 fn cancelled_is_not_retryable_and_not_quality_failure() {
513 assert!(!ToolErrorCategory::Cancelled.is_retryable());
514 assert!(!ToolErrorCategory::Cancelled.is_quality_failure());
515 assert!(!ToolErrorCategory::Cancelled.needs_parameter_reformat());
516 }
517}