1use anyhow::Error;
2use serde::{Deserialize, Serialize};
3use serde_json::{Value, json};
4use std::borrow::Cow;
5use vtcode_commons::ErrorCategory;
6
7use crate::retry::is_command_tool;
8use crate::retry::{RetryDecision, RetryPolicy};
9
10#[derive(Debug, Clone, Serialize, Deserialize, Default)]
11pub struct ToolErrorDebugContext {
12 pub surface: Option<String>,
13 pub attempt: Option<u32>,
14 pub invocation_id: Option<String>,
15 pub metadata: Vec<(String, String)>,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct ToolExecutionError {
20 pub tool_name: String,
21 pub error_type: ToolErrorType,
22 pub category: ErrorCategory,
23 pub message: String,
24 pub retryable: bool,
25 pub is_recoverable: bool,
26 pub recovery_suggestions: Vec<Cow<'static, str>>,
27 pub retry_delay_ms: Option<u64>,
28 pub retry_after_ms: Option<u64>,
29 pub circuit_breaker_impact: bool,
30 pub partial_state_possible: bool,
31 pub rollback_performed: bool,
32 pub debug_context: Option<ToolErrorDebugContext>,
33 pub original_error: Option<String>,
34}
35
36#[derive(Debug, Clone, Copy, Serialize, Deserialize)] pub enum ToolErrorType {
38 InvalidParameters,
39 ToolNotFound,
40 PermissionDenied,
41 ResourceNotFound,
42 NetworkError,
43 Timeout,
44 ExecutionError,
45 PolicyViolation,
46}
47
48impl ToolErrorType {
49 #[must_use]
51 pub const fn as_str(&self) -> &'static str {
52 match self {
53 Self::InvalidParameters => "InvalidParameters",
54 Self::ToolNotFound => "ToolNotFound",
55 Self::PermissionDenied => "PermissionDenied",
56 Self::ResourceNotFound => "ResourceNotFound",
57 Self::NetworkError => "NetworkError",
58 Self::Timeout => "Timeout",
59 Self::ExecutionError => "ExecutionError",
60 Self::PolicyViolation => "PolicyViolation",
61 }
62 }
63}
64
65impl ToolExecutionError {
66 #[inline]
67 #[must_use]
68 pub fn new(
69 tool_name: impl Into<String>,
70 error_type: ToolErrorType,
71 message: impl Into<String>,
72 ) -> Self {
73 let tool_name = tool_name.into();
74 let message = message.into();
75 let category = ErrorCategory::from(error_type);
76 let (retryable, is_recoverable, recovery_suggestions) =
77 generate_recovery_info(tool_name.as_str(), category, error_type);
78
79 Self {
83 tool_name,
84 error_type,
85 category,
86 message,
87 retryable,
88 is_recoverable,
89 recovery_suggestions,
90 retry_delay_ms: None,
91 retry_after_ms: None,
92 circuit_breaker_impact: category.should_trip_circuit_breaker(),
93 partial_state_possible: false,
94 rollback_performed: false,
95 debug_context: None,
96 original_error: None,
97 }
98 }
99
100 #[inline]
101 #[must_use]
102 pub fn with_original_error(
103 tool_name: impl Into<String>,
104 error_type: ToolErrorType,
105 message: impl Into<String>,
106 original_error: impl Into<String>,
107 ) -> Self {
108 let mut error = Self::new(tool_name, error_type, message);
109 error.original_error = Some(original_error.into());
110 error
111 }
112
113 #[must_use]
114 pub fn from_anyhow(
115 tool_name: impl Into<String>,
116 error: &Error,
117 attempt_index: u32,
118 partial_state_possible: bool,
119 rollback_performed: bool,
120 surface: Option<&str>,
121 ) -> Self {
122 let tool_name = tool_name.into();
123 let mut structured = Self::with_original_error(
124 tool_name.clone(),
125 classify_error(error),
126 error.to_string(),
127 format!("{error:#}"),
128 );
129 structured = RetryPolicy::default().apply_to_tool_execution_error(
130 structured,
131 attempt_index,
132 Some(tool_name.as_str()),
133 );
134 structured.partial_state_possible = partial_state_possible;
135 structured.rollback_performed = rollback_performed;
136 structured = apply_explicit_error_state(structured, tool_name.as_str(), error);
137 if let Some(surface) = surface {
138 structured = structured.with_surface(surface);
139 }
140 structured
141 }
142
143 #[must_use]
144 pub fn policy_violation(tool_name: impl Into<String>, message: impl Into<String>) -> Self {
145 Self::new(tool_name, ToolErrorType::PolicyViolation, message)
146 }
147
148 #[must_use]
149 pub fn with_retry_decision(mut self, decision: RetryDecision) -> Self {
150 self.category = decision.category;
151 self.retryable = decision.retryable;
152 self.retry_delay_ms = decision.delay.map(|delay| delay.as_millis() as u64);
153 self.retry_after_ms = decision.retry_after.map(|delay| delay.as_millis() as u64);
154 self.circuit_breaker_impact = decision.category.should_trip_circuit_breaker();
155 self
156 }
157
158 #[must_use]
159 pub fn with_partial_state(
160 mut self,
161 partial_state_possible: bool,
162 rollback_performed: bool,
163 ) -> Self {
164 self.partial_state_possible = partial_state_possible;
165 self.rollback_performed = rollback_performed;
166 self
167 }
168
169 #[must_use]
170 pub fn with_surface(mut self, surface: impl Into<String>) -> Self {
171 let debug = self
172 .debug_context
173 .get_or_insert_with(ToolErrorDebugContext::default);
174 debug.surface = Some(surface.into());
175 self
176 }
177
178 #[must_use]
179 pub fn with_attempt(mut self, attempt: u32) -> Self {
180 let debug = self
181 .debug_context
182 .get_or_insert_with(ToolErrorDebugContext::default);
183 debug.attempt = Some(attempt);
184 self
185 }
186
187 #[must_use]
188 pub fn with_invocation_id(mut self, invocation_id: impl Into<String>) -> Self {
189 let debug = self
190 .debug_context
191 .get_or_insert_with(ToolErrorDebugContext::default);
192 debug.invocation_id = Some(invocation_id.into());
193 self
194 }
195
196 #[must_use]
197 pub fn with_debug_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
198 let debug = self
199 .debug_context
200 .get_or_insert_with(ToolErrorDebugContext::default);
201 debug.metadata.push((key.into(), value.into()));
202 self
203 }
204
205 #[must_use]
206 pub fn with_tool_call_context(mut self, tool_name: &str, args: &Value) -> Self {
207 self.tool_name = tool_name.to_string();
208
209 if tool_name == crate::config::constants::tools::APPLY_PATCH {
210 return self;
211 }
212
213 let intent = crate::tools::tool_intent::classify_tool_intent(tool_name, args);
214 if intent.mutating || is_command_tool(tool_name) {
215 self.partial_state_possible = true;
216 }
217
218 self
219 }
220
221 #[must_use]
222 pub fn attempts_made(&self) -> Option<u32> {
223 self.debug_context
224 .as_ref()
225 .and_then(|context| context.attempt)
226 }
227
228 #[must_use]
229 pub fn retry_summary(&self) -> Option<String> {
230 let retry_count = self
231 .attempts_made()
232 .map(|attempts| attempts.saturating_sub(1))
233 .unwrap_or(0);
234
235 let mut summary = if matches!(self.category, ErrorCategory::CircuitOpen) {
236 Some("The service is pausing new calls after repeated transient failures.".to_string())
237 } else if retry_count > 0 {
238 let suffix = if retry_count == 1 { "" } else { "s" };
239 Some(format!(
240 "Retried {retry_count} time{suffix} before failing."
241 ))
242 } else {
243 None
244 };
245
246 if let Some(delay_ms) = self.retry_after_ms.or(self.retry_delay_ms) {
247 let delay = format_retry_delay(delay_ms);
248 match summary.as_mut() {
249 Some(existing) => {
250 existing.push(' ');
251 existing.push_str("Recommended wait: ");
252 existing.push_str(&delay);
253 existing.push('.');
254 }
255 None => {
256 summary = Some(format!("Recommended wait: {delay}."));
257 }
258 }
259 }
260
261 summary
262 }
263
264 #[must_use]
265 pub fn user_message(&self) -> String {
266 let mut message = format!("[{}] {}", self.category.user_label(), self.message);
267
268 if self.rollback_performed {
269 message.push_str(" Any partial changes were rolled back.");
270 } else if self.partial_state_possible {
271 message.push_str(" Partial changes may still exist.");
272 }
273
274 if let Some(retry_summary) = self.retry_summary() {
275 message.push(' ');
276 message.push_str(&retry_summary);
277 }
278
279 if let Some(next_action) = self.recovery_suggestions.first() {
280 message.push_str(" Next: ");
281 message.push_str(next_action.as_ref());
282 }
283
284 message
285 }
286
287 #[must_use]
288 pub fn retry_delay(&self) -> Option<std::time::Duration> {
289 self.retry_delay_ms.map(std::time::Duration::from_millis)
290 }
291
292 #[must_use]
293 pub fn retry_after(&self) -> Option<std::time::Duration> {
294 self.retry_after_ms.map(std::time::Duration::from_millis)
295 }
296
297 #[must_use]
298 pub fn from_tool_output(output: &Value) -> Option<Self> {
299 let error_payload = output.get("error")?;
300 Self::from_error_payload(error_payload)
301 }
302
303 #[must_use]
304 pub fn from_error_payload(error_payload: &Value) -> Option<Self> {
305 if let Some(inner) = error_payload.get("error") {
306 return Self::from_error_payload(inner);
307 }
308
309 if error_payload.is_object() && error_payload.get("message").is_some() {
310 return serde_json::from_value(error_payload.clone())
311 .ok()
312 .or_else(|| {
313 let tool_name = error_payload
314 .get("tool_name")
315 .and_then(Value::as_str)
316 .unwrap_or("tool");
317 let message = error_payload
318 .get("message")
319 .and_then(Value::as_str)
320 .unwrap_or("Unknown tool execution error");
321 let category = error_payload
322 .get("category")
323 .and_then(|value| serde_json::from_value(value.clone()).ok())
324 .unwrap_or_else(|| vtcode_commons::classify_error_message(message));
325 let error_type = error_payload
326 .get("error_type")
327 .and_then(Value::as_str)
328 .map(parse_error_type)
329 .unwrap_or_else(|| ToolErrorType::from(category));
330 let mut structured =
331 Self::new(tool_name.to_string(), error_type, message.to_string());
332 structured.category = category;
333 structured.retryable = error_payload
334 .get("retryable")
335 .and_then(Value::as_bool)
336 .unwrap_or(structured.retryable);
337 structured.is_recoverable = error_payload
338 .get("is_recoverable")
339 .and_then(Value::as_bool)
340 .unwrap_or(structured.is_recoverable);
341 structured.retry_delay_ms =
342 error_payload.get("retry_delay_ms").and_then(Value::as_u64);
343 structured.retry_after_ms =
344 error_payload.get("retry_after_ms").and_then(Value::as_u64);
345 structured.circuit_breaker_impact = error_payload
346 .get("circuit_breaker_impact")
347 .and_then(Value::as_bool)
348 .unwrap_or(structured.circuit_breaker_impact);
349 structured.partial_state_possible = error_payload
350 .get("partial_state_possible")
351 .and_then(Value::as_bool)
352 .unwrap_or(false);
353 structured.rollback_performed = error_payload
354 .get("rollback_performed")
355 .and_then(Value::as_bool)
356 .unwrap_or(false);
357 structured.original_error = error_payload
358 .get("original_error")
359 .and_then(Value::as_str)
360 .map(ToOwned::to_owned);
361 Some(structured)
362 });
363 }
364
365 error_payload.as_str().map(|message| {
366 let category = vtcode_commons::classify_error_message(message);
367 Self::new(
368 "tool".to_string(),
369 ToolErrorType::from(category),
370 message.to_string(),
371 )
372 })
373 }
374
375 #[must_use]
376 pub fn to_json_value(&self) -> Value {
377 json!({
378 "error": {
379 "tool_name": self.tool_name,
380 "error_type": self.error_type.as_str(),
381 "category": self.category,
382 "message": self.message,
383 "retryable": self.retryable,
384 "is_recoverable": self.is_recoverable,
385 "recovery_suggestions": self.recovery_suggestions,
386 "retry_delay_ms": self.retry_delay_ms,
387 "retry_after_ms": self.retry_after_ms,
388 "circuit_breaker_impact": self.circuit_breaker_impact,
389 "partial_state_possible": self.partial_state_possible,
390 "rollback_performed": self.rollback_performed,
391 "debug_context": self.debug_context,
392 "original_error": self.original_error,
393 }
394 })
395 }
396}
397
398impl std::fmt::Display for ToolExecutionError {
399 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
400 f.write_str(&self.message)
401 }
402}
403
404impl std::error::Error for ToolExecutionError {}
405
406pub fn classify_error(error: &Error) -> ToolErrorType {
413 let category = vtcode_commons::classify_anyhow_error(error);
414 ToolErrorType::from(category)
415}
416
417#[inline]
420fn generate_recovery_info(
421 tool_name: &str,
422 category: ErrorCategory,
423 error_type: ToolErrorType,
424) -> (bool, bool, Vec<Cow<'static, str>>) {
425 let is_recoverable = category.is_retryable()
426 || matches!(
427 error_type,
428 ToolErrorType::InvalidParameters
429 | ToolErrorType::PermissionDenied
430 | ToolErrorType::ResourceNotFound
431 );
432 let retryable = if matches!(error_type, ToolErrorType::Timeout) && is_command_tool(tool_name) {
433 false
434 } else {
435 category.is_retryable()
436 };
437 (retryable, is_recoverable, category.recovery_suggestions())
438}
439
440fn parse_error_type(raw: &str) -> ToolErrorType {
441 match raw {
442 "InvalidParameters" => ToolErrorType::InvalidParameters,
443 "ToolNotFound" => ToolErrorType::ToolNotFound,
444 "PermissionDenied" => ToolErrorType::PermissionDenied,
445 "ResourceNotFound" => ToolErrorType::ResourceNotFound,
446 "NetworkError" => ToolErrorType::NetworkError,
447 "Timeout" => ToolErrorType::Timeout,
448 "ExecutionError" => ToolErrorType::ExecutionError,
449 "PolicyViolation" => ToolErrorType::PolicyViolation,
450 _ => ToolErrorType::ExecutionError,
451 }
452}
453
454fn format_retry_delay(delay_ms: u64) -> String {
455 if delay_ms >= 1_000 {
456 format!("{:.1}s", delay_ms as f64 / 1_000.0)
457 } else {
458 format!("{delay_ms}ms")
459 }
460}
461
462fn apply_explicit_error_state(
463 mut error: ToolExecutionError,
464 tool_name: &str,
465 source: &Error,
466) -> ToolExecutionError {
467 if tool_name != crate::config::constants::tools::APPLY_PATCH {
468 return error;
469 }
470
471 if let Some(patch_error) = source.downcast_ref::<crate::tools::editing::PatchError>() {
472 match patch_error {
473 crate::tools::editing::PatchError::RolledBack { .. } => {
474 error = error.with_partial_state(false, true);
475 }
476 crate::tools::editing::PatchError::Recovery { .. } => {
477 error = error.with_partial_state(true, false);
478 }
479 _ => {}
480 }
481 }
482
483 error
484}
485
486impl From<ErrorCategory> for ToolErrorType {
489 fn from(cat: ErrorCategory) -> Self {
490 match cat {
491 ErrorCategory::InvalidParameters => ToolErrorType::InvalidParameters,
492 ErrorCategory::ToolNotFound => ToolErrorType::ToolNotFound,
493 ErrorCategory::ResourceNotFound => ToolErrorType::ResourceNotFound,
494 ErrorCategory::PermissionDenied => ToolErrorType::PermissionDenied,
495 ErrorCategory::Network | ErrorCategory::ServiceUnavailable => {
496 ToolErrorType::NetworkError
497 }
498 ErrorCategory::Timeout => ToolErrorType::Timeout,
499 ErrorCategory::PolicyViolation | ErrorCategory::PlanModeViolation => {
500 ToolErrorType::PolicyViolation
501 }
502 ErrorCategory::RateLimit => ToolErrorType::NetworkError,
503 ErrorCategory::CircuitOpen => ToolErrorType::ExecutionError,
504 ErrorCategory::Authentication => ToolErrorType::PermissionDenied,
505 ErrorCategory::SandboxFailure => ToolErrorType::PolicyViolation,
506 ErrorCategory::ResourceExhausted => ToolErrorType::ExecutionError,
507 ErrorCategory::Cancelled => ToolErrorType::ExecutionError,
508 ErrorCategory::ExecutionError => ToolErrorType::ExecutionError,
509 }
510 }
511}
512
513impl From<ToolErrorType> for ErrorCategory {
514 fn from(t: ToolErrorType) -> Self {
515 match t {
516 ToolErrorType::InvalidParameters => ErrorCategory::InvalidParameters,
517 ToolErrorType::ToolNotFound => ErrorCategory::ToolNotFound,
518 ToolErrorType::ResourceNotFound => ErrorCategory::ResourceNotFound,
519 ToolErrorType::PermissionDenied => ErrorCategory::PermissionDenied,
520 ToolErrorType::NetworkError => ErrorCategory::Network,
521 ToolErrorType::Timeout => ErrorCategory::Timeout,
522 ToolErrorType::PolicyViolation => ErrorCategory::PolicyViolation,
523 ToolErrorType::ExecutionError => ErrorCategory::ExecutionError,
524 }
525 }
526}
527
528#[cfg(test)]
529mod tests {
530 use super::*;
531 use anyhow::anyhow;
532
533 #[test]
534 fn classify_error_marks_rate_limit_as_network_error() {
535 let err = anyhow!("provider returned 429 Too Many Requests");
536 assert!(matches!(classify_error(&err), ToolErrorType::NetworkError));
537 }
538
539 #[test]
540 fn classify_error_marks_service_unavailable_as_network_error() {
541 let err = anyhow!("503 Service Unavailable");
542 assert!(matches!(classify_error(&err), ToolErrorType::NetworkError));
543 }
544
545 #[test]
546 fn classify_error_marks_weekly_usage_limit_as_execution_error() {
547 let err = anyhow!("you have reached your weekly usage limit");
548 assert!(matches!(
549 classify_error(&err),
550 ToolErrorType::ExecutionError
551 ));
552 }
553
554 #[test]
555 fn classify_error_marks_tool_not_found() {
556 let err = anyhow!("unknown tool: ask_questions");
557 assert!(matches!(classify_error(&err), ToolErrorType::ToolNotFound));
558 }
559
560 #[test]
561 fn classify_error_marks_policy_violation_before_permission() {
562 let err = anyhow!("tool permission denied by policy");
563 assert!(matches!(
564 classify_error(&err),
565 ToolErrorType::PolicyViolation
566 ));
567 }
568
569 #[test]
570 fn tool_call_context_marks_mutating_tools_as_partial_state_possible() {
571 let error = ToolExecutionError::new(
572 "write_file".to_string(),
573 ToolErrorType::ExecutionError,
574 "write failed".to_string(),
575 )
576 .with_tool_call_context(
577 crate::config::constants::tools::WRITE_FILE,
578 &serde_json::json!({"path": "note.txt", "content": "hello"}),
579 );
580
581 assert!(error.partial_state_possible);
582 assert!(!error.rollback_performed);
583 }
584
585 #[test]
586 fn tool_call_context_marks_apply_patch_failures_as_rolled_back() {
587 let source = Error::new(crate::tools::editing::PatchError::RolledBack {
588 original: Box::new(crate::tools::editing::PatchError::SegmentNotFound {
589 path: "src/lib.rs".to_string(),
590 snippet: "fn main()".to_string(),
591 }),
592 });
593 let error = ToolExecutionError::from_anyhow(
594 crate::config::constants::tools::APPLY_PATCH,
595 &source,
596 0,
597 false,
598 false,
599 None,
600 );
601
602 assert!(!error.partial_state_possible);
603 assert!(error.rollback_performed);
604 }
605
606 #[test]
607 fn user_message_includes_retry_summary_and_wait() {
608 let mut error = ToolExecutionError::new(
609 crate::config::constants::tools::READ_FILE.to_string(),
610 ToolErrorType::ExecutionError,
611 "read failed".to_string(),
612 )
613 .with_attempt(2);
614 error.retry_delay_ms = Some(1_500);
615
616 let message = error.user_message();
617
618 assert!(message.contains("Retried 1 time before failing."));
619 assert!(message.contains("Recommended wait: 1.5s."));
620 }
621}