1use anyhow::Error;
2use serde::{Deserialize, Serialize};
3use serde_json::{Value, json};
4use std::borrow::Cow;
5use vtcode_commons::ErrorCategory;
6
7use crate::retry::{RetryDecision, RetryPolicy, RetryPolicyCoreExt};
8use crate::tools::tool_intent::is_command_tool;
9
10const EXEC_SESSION_NOT_FOUND_CODE: &str = "exec_session_not_found";
11
12#[derive(Debug, Clone, Serialize, Deserialize, Default)]
13pub struct ToolErrorDebugContext {
14 pub surface: Option<String>,
15 pub attempt: Option<u32>,
16 pub invocation_id: Option<String>,
17 pub metadata: Vec<(String, String)>,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
22pub enum PatchFailure {
23 ContextMismatch { path: String, evidence: String },
24 Other,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum EditFailureKind {
30 NoMatch,
31 AmbiguousMatch,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct EditLocation {
36 pub start_line: usize,
37 pub end_line: usize,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, thiserror::Error)]
42#[error(
43 "Literal edit {failure_kind:?} in '{path}'; refresh current source and correct the edit. Use anchored apply_patch to disambiguate. Never retry unchanged failed input."
44)]
45pub struct EditFailure {
46 pub failure_kind: EditFailureKind,
47 pub path: String,
48 pub candidate_locations: Vec<EditLocation>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub next_read_args: Option<Box<Value>>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub next_search_args: Option<Box<Value>>,
53}
54
55impl EditFailure {
56 pub(crate) fn new(kind: EditFailureKind, path: &std::path::Path, locations: Vec<EditLocation>, old: &str) -> Self {
57 let next_read_args = locations.first().map(|location| {
58 let start = location.start_line.saturating_sub(3).max(1);
59 let limit = (location.end_line + 3 - start + 1).min(200);
60 Box::new(json!({"path":path, "offset":start, "limit":limit, "condense":false, "max_tokens":6000}))
61 });
62 let query = old
63 .lines()
64 .find(|line| !line.trim().is_empty())
65 .unwrap_or(old)
66 .trim()
67 .chars()
68 .take(120)
69 .collect::<String>();
70 let next_search_args = next_read_args
71 .is_none()
72 .then_some(query)
73 .filter(|query| !query.is_empty())
74 .map(|query| Box::new(json!({"path":path, "query":query, "max_results":5})));
75 Self {
76 failure_kind: kind,
77 path: path.to_string_lossy().into_owned(),
78 candidate_locations: locations,
79 next_read_args,
80 next_search_args,
81 }
82 }
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct ToolExecutionError {
87 pub tool_name: String,
88 pub error_type: ToolErrorType,
89 pub category: ErrorCategory,
90 pub message: String,
91 pub retryable: bool,
92 pub is_recoverable: bool,
93 pub recovery_suggestions: Vec<Cow<'static, str>>,
94 pub retry_delay_ms: Option<u64>,
95 pub retry_after_ms: Option<u64>,
96 pub circuit_breaker_impact: bool,
97 pub partial_state_possible: bool,
98 pub rollback_performed: bool,
99 pub debug_context: Option<ToolErrorDebugContext>,
100 pub original_error: Option<String>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub patch_failure: Option<PatchFailure>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub inspection_failure: Option<Value>,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub edit_failure: Option<EditFailure>,
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
110pub enum ToolErrorType {
111 InvalidParameters,
112 ToolNotFound,
113 PermissionDenied,
114 ResourceNotFound,
115 NetworkError,
116 Timeout,
117 ExecutionError,
118 PolicyViolation,
119}
120
121impl ToolErrorType {
122 #[must_use]
124 pub const fn as_str(&self) -> &'static str {
125 match self {
126 Self::InvalidParameters => "InvalidParameters",
127 Self::ToolNotFound => "ToolNotFound",
128 Self::PermissionDenied => "PermissionDenied",
129 Self::ResourceNotFound => "ResourceNotFound",
130 Self::NetworkError => "NetworkError",
131 Self::Timeout => "Timeout",
132 Self::ExecutionError => "ExecutionError",
133 Self::PolicyViolation => "PolicyViolation",
134 }
135 }
136}
137
138impl ToolExecutionError {
139 #[inline]
140 #[must_use]
141 pub fn new(tool_name: impl Into<String>, error_type: ToolErrorType, message: impl Into<String>) -> Self {
142 Self::from_parts(tool_name.into(), ErrorCategory::from(error_type), error_type, message.into())
143 }
144
145 #[must_use]
151 fn from_category(tool_name: impl Into<String>, category: ErrorCategory, message: impl Into<String>) -> Self {
152 Self::from_parts(tool_name.into(), category, ToolErrorType::from(category), message.into())
153 }
154
155 fn from_parts(tool_name: String, category: ErrorCategory, error_type: ToolErrorType, message: String) -> Self {
156 let (retryable, is_recoverable, recovery_suggestions) =
157 generate_recovery_info(tool_name.as_str(), category, error_type);
158
159 Self {
160 tool_name,
161 error_type,
162 category,
163 message,
164 retryable,
165 is_recoverable,
166 recovery_suggestions,
167 retry_delay_ms: None,
168 retry_after_ms: None,
169 circuit_breaker_impact: category.should_trip_circuit_breaker(),
170 partial_state_possible: false,
171 rollback_performed: false,
172 debug_context: None,
173 original_error: None,
174 patch_failure: None,
175 inspection_failure: None,
176 edit_failure: None,
177 }
178 }
179
180 #[inline]
181 #[must_use]
182 pub fn with_original_error(
183 tool_name: impl Into<String>,
184 error_type: ToolErrorType,
185 message: impl Into<String>,
186 original_error: impl Into<String>,
187 ) -> Self {
188 let mut error = Self::new(tool_name, error_type, message);
189 error.original_error = Some(original_error.into());
190 error
191 }
192
193 #[must_use]
194 pub fn from_anyhow(
195 tool_name: impl Into<String>,
196 error: &Error,
197 attempt_index: u32,
198 partial_state_possible: bool,
199 rollback_performed: bool,
200 surface: Option<&str>,
201 ) -> Self {
202 let tool_name = tool_name.into();
203 if let Some(edit) = error.downcast_ref::<EditFailure>() {
204 let mut structured = Self::new(tool_name, ToolErrorType::InvalidParameters, edit.to_string());
205 structured.edit_failure = Some(edit.clone());
206 structured.retryable = false;
207 structured.is_recoverable = true;
208 structured.circuit_breaker_impact = false;
209 structured.recovery_suggestions = vec![Cow::Borrowed(
210 "Use edit_failure.next_read_args when present; otherwise use next_search_args, then read a targeted range at the returned location. One uncached read of at most 200 lines per canonical path/user turn shares the patch recovery quota. Correct old_str or use anchored apply_patch; never retry unchanged failed input.",
211 )];
212 structured = structured.with_attempt(attempt_index);
213 if let Some(surface) = surface {
214 structured = structured.with_surface(surface);
215 }
216 return structured;
217 }
218 if let Some(inspection) = error.downcast_ref::<crate::tools::source_inspection::InspectionRequired>() {
219 let mut structured = Self::new(tool_name, ToolErrorType::InvalidParameters, inspection.to_string());
220 structured.inspection_failure = Some(inspection.details.clone());
221 structured.retryable = false;
222 structured.is_recoverable = true;
223 structured.circuit_breaker_impact = false;
224 structured.recovery_suggestions = vec![Cow::Borrowed(
225 if inspection.details.get("next_read_args").is_some() {
226 "Use inspection_failure.next_read_args to refresh the missing source, then retry a bounded edit."
227 } else {
228 "Use apply_patch for authored file changes; mechanical commands require an exact trusted-user exemption."
229 },
230 )];
231 return structured;
232 }
233 if let Some(session_error) = error.downcast_ref::<crate::tools::exec_session::ExecSessionNotFound>() {
236 let mut structured =
237 Self::from_category(tool_name, ErrorCategory::ResourceNotFound, session_error.to_string())
238 .with_debug_metadata("failure_code", EXEC_SESSION_NOT_FOUND_CODE);
239 structured.original_error =
240 Some(vtcode_commons::formatting::head_tail_truncate(&format!("{error:#}"), 640, " ... ").0);
241 structured.partial_state_possible = partial_state_possible;
242 structured.rollback_performed = rollback_performed;
243 structured.recovery_suggestions = vec![Cow::Borrowed(
244 "Recover the exact session ID from the original response. Reuse recorded completion output; rerun only if fresh execution is still needed.",
245 )];
246 if let Some(surface) = surface {
247 structured = structured.with_surface(surface);
248 }
249 return structured;
250 }
251 if let Some(patch_error) = error.downcast_ref::<crate::tools::editing::PatchError>() {
252 use crate::tools::editing::PatchError;
253 let category = match patch_error {
254 PatchError::InvalidPath { .. } => ErrorCategory::PolicyViolation,
255 PatchError::MissingFile { .. } => ErrorCategory::ResourceNotFound,
256 PatchError::EmptyInput
257 | PatchError::NoOperations
258 | PatchError::InvalidFormat(_)
259 | PatchError::InvalidHunk { .. } => ErrorCategory::InvalidParameters,
260 PatchError::Io { source, .. } if source.kind() == std::io::ErrorKind::PermissionDenied => {
261 ErrorCategory::PermissionDenied
262 }
263 _ => ErrorCategory::ExecutionError,
264 };
265 let mut structured = Self::from_category(tool_name.clone(), category, patch_error.to_string());
266 structured.original_error =
267 Some(vtcode_commons::formatting::head_tail_truncate(&format!("{error:#}"), 640, " ... ").0);
268 structured.patch_failure = Some(match patch_error.context_mismatch() {
269 Some((path, evidence)) => PatchFailure::ContextMismatch {
270 path: path.to_string(),
271 evidence: vtcode_commons::formatting::head_tail_truncate(evidence, 320, " ... ").0,
272 },
273 None => PatchFailure::Other,
274 });
275 if let Some(path) = structured.patch_context_mismatch_path() {
276 structured.message = format!(
277 "Patch context mismatch in '{path}': use complete current lines and preserve internal whitespace."
278 );
279 structured.recovery_suggestions = vec![Cow::Borrowed(
280 "Read the affected path once with a file read limit of 1-200 lines or a single sed -n range, then rebuild apply_patch with complete current context. Never retry the unchanged failed patch. Do not probe matching with scratch edits or inspect the patch implementation. One fresh recovery read per path per turn can pass either read cap; other limits still apply.",
281 )];
282 } else if category == ErrorCategory::InvalidParameters {
283 structured.recovery_suggestions = vec![Cow::Borrowed(
284 crate::tools::apply_patch::APPLY_PATCH_ARGUMENT_CORRECTION,
285 )];
286 } else {
287 structured.recovery_suggestions = vec![Cow::Borrowed(
288 "Resolve the reported patch target or filesystem error before retrying; retain permission and workspace boundaries.",
289 )];
290 }
291 structured.retryable = false;
292 structured.is_recoverable =
293 !matches!(category, ErrorCategory::PolicyViolation | ErrorCategory::PermissionDenied);
294 structured.circuit_breaker_impact = false;
295 structured = apply_explicit_error_state(structured, tool_name.as_str(), error);
296 if let Some(surface) = surface {
297 structured = structured.with_surface(surface);
298 }
299 return structured;
300 }
301 let category = vtcode_commons::classify_anyhow_error(error);
302 let mut structured = Self::from_category(tool_name.clone(), category, error.to_string());
303 structured.original_error = Some(format!("{error:#}"));
304 structured =
305 RetryPolicy::default().apply_to_tool_execution_error(structured, attempt_index, Some(tool_name.as_str()));
306 structured.partial_state_possible = partial_state_possible;
307 structured.rollback_performed = rollback_performed;
308 structured = apply_explicit_error_state(structured, tool_name.as_str(), error);
309 if let Some(surface) = surface {
310 structured = structured.with_surface(surface);
311 }
312 structured
313 }
314
315 #[must_use]
317 pub fn patch_context_mismatch_path(&self) -> Option<&str> {
318 match &self.patch_failure {
319 Some(PatchFailure::ContextMismatch { path, .. }) => Some(path),
320 _ => self.edit_failure.as_ref().map(|failure| failure.path.as_str()),
321 }
322 }
323
324 #[must_use]
326 pub fn is_exec_session_not_found(&self) -> bool {
327 self.category == ErrorCategory::ResourceNotFound
328 && self.debug_context.as_ref().is_some_and(|context| {
329 context
330 .metadata
331 .iter()
332 .any(|(key, value)| key == "failure_code" && value == EXEC_SESSION_NOT_FOUND_CODE)
333 })
334 }
335
336 #[must_use]
337 pub fn policy_violation(tool_name: impl Into<String>, message: impl Into<String>) -> Self {
338 Self::new(tool_name, ToolErrorType::PolicyViolation, message)
339 }
340
341 #[must_use]
342 pub fn with_retry_decision(mut self, decision: RetryDecision) -> Self {
343 self.category = decision.category;
344 self.error_type = ToolErrorType::from(decision.category);
349 self.retryable = decision.retryable;
350 self.retry_delay_ms = decision.delay.map(|delay| delay.as_millis() as u64);
351 self.retry_after_ms = decision.retry_after.map(|delay| delay.as_millis() as u64);
352 self.circuit_breaker_impact = decision.category.should_trip_circuit_breaker();
353 self
354 }
355
356 #[must_use]
357 pub fn with_partial_state(mut self, partial_state_possible: bool, rollback_performed: bool) -> Self {
358 self.partial_state_possible = partial_state_possible;
359 self.rollback_performed = rollback_performed;
360 self
361 }
362
363 #[must_use]
364 pub fn with_surface(mut self, surface: impl Into<String>) -> Self {
365 let debug = self.debug_context.get_or_insert_with(ToolErrorDebugContext::default);
366 debug.surface = Some(surface.into());
367 self
368 }
369
370 #[must_use]
371 pub fn with_attempt(mut self, attempt: u32) -> Self {
372 let debug = self.debug_context.get_or_insert_with(ToolErrorDebugContext::default);
373 debug.attempt = Some(attempt);
374 self
375 }
376
377 #[must_use]
378 pub fn with_invocation_id(mut self, invocation_id: impl Into<String>) -> Self {
379 let debug = self.debug_context.get_or_insert_with(ToolErrorDebugContext::default);
380 debug.invocation_id = Some(invocation_id.into());
381 self
382 }
383
384 #[must_use]
385 pub fn with_debug_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
386 let debug = self.debug_context.get_or_insert_with(ToolErrorDebugContext::default);
387 debug.metadata.push((key.into(), value.into()));
388 self
389 }
390
391 #[must_use]
392 pub fn with_tool_call_context(mut self, tool_name: &str, args: &Value) -> Self {
393 self.tool_name = tool_name.to_string();
394
395 if self.inspection_failure.is_some() || self.edit_failure.is_some() {
396 self.partial_state_possible = false;
397 return self;
398 }
399 if tool_name == crate::config::constants::tools::APPLY_PATCH {
400 return self;
401 }
402
403 let intent = crate::tools::tool_intent::classify_tool_intent(tool_name, args);
404 if intent.mutating || is_command_tool(tool_name) {
405 self.partial_state_possible = true;
406 }
407
408 self
409 }
410
411 #[must_use]
412 pub fn attempts_made(&self) -> Option<u32> {
413 self.debug_context.as_ref().and_then(|context| context.attempt)
414 }
415
416 #[must_use]
417 pub fn retry_summary(&self) -> Option<String> {
418 let retry_count = self.attempts_made().map(|attempts| attempts.saturating_sub(1)).unwrap_or(0);
419
420 let mut summary = if matches!(self.category, ErrorCategory::CircuitOpen) {
421 Some("The service is pausing new calls after repeated transient failures.".to_string())
422 } else if retry_count > 0 {
423 let suffix = if retry_count == 1 { "" } else { "s" };
424 Some(format!("Retried {retry_count} time{suffix} before failing."))
425 } else {
426 None
427 };
428
429 if let Some(delay_ms) = self.retry_after_ms.or(self.retry_delay_ms) {
430 let delay = format_retry_delay(delay_ms);
431 match summary.as_mut() {
432 Some(existing) => {
433 existing.push(' ');
434 existing.push_str("Recommended wait: ");
435 existing.push_str(&delay);
436 existing.push('.');
437 }
438 None => {
439 summary = Some(format!("Recommended wait: {delay}."));
440 }
441 }
442 }
443
444 summary
445 }
446
447 #[must_use]
448 pub fn user_message(&self) -> String {
449 let mut message = format!("[{}] {}", self.category.user_label(), self.message);
450
451 if self.rollback_performed {
452 message.push_str(" Any partial changes were rolled back.");
453 } else if self.partial_state_possible {
454 message.push_str(" Partial changes may still exist.");
455 }
456
457 if let Some(retry_summary) = self.retry_summary() {
458 message.push(' ');
459 message.push_str(&retry_summary);
460 }
461
462 if let Some(next_action) = self.recovery_suggestions.first() {
463 message.push_str(" Next: ");
464 message.push_str(next_action.as_ref());
465 }
466
467 message
468 }
469
470 #[must_use]
471 pub fn retry_delay(&self) -> Option<std::time::Duration> {
472 self.retry_delay_ms.map(std::time::Duration::from_millis)
473 }
474
475 #[must_use]
476 pub fn retry_after(&self) -> Option<std::time::Duration> {
477 self.retry_after_ms.map(std::time::Duration::from_millis)
478 }
479
480 #[must_use]
481 pub fn from_tool_output(output: &Value) -> Option<Self> {
482 let error_payload = output.get("error")?;
483 Self::from_error_payload(error_payload)
484 }
485
486 #[must_use]
487 pub fn from_error_payload(error_payload: &Value) -> Option<Self> {
488 if let Some(inner) = error_payload.get("error") {
489 return Self::from_error_payload(inner);
490 }
491
492 if error_payload.is_object() && error_payload.get("message").is_some() {
493 if let Ok(structured) = serde_json::from_value::<Self>(error_payload.clone()) {
502 return Some(structured);
503 }
504
505 let tool_name = error_payload.get("tool_name").and_then(Value::as_str).unwrap_or("tool");
509 let message = error_payload
510 .get("message")
511 .and_then(Value::as_str)
512 .unwrap_or("Unknown tool execution error");
513 let category = error_payload
514 .get("category")
515 .and_then(|value| serde_json::from_value(value.clone()).ok())
516 .unwrap_or_else(|| vtcode_commons::classify_error_message(message));
517 let error_type = error_payload
518 .get("error_type")
519 .and_then(Value::as_str)
520 .map(parse_error_type)
521 .unwrap_or_else(|| ToolErrorType::from(category));
522 let mut structured = Self::new(tool_name.to_string(), error_type, message.to_string());
523 structured.category = category;
524 structured.original_error = error_payload
525 .get("original_error")
526 .and_then(Value::as_str)
527 .map(ToOwned::to_owned);
528 return Some(structured);
529 }
530
531 error_payload.as_str().map(|message| {
532 let category = vtcode_commons::classify_error_message(message);
533 Self::new("tool".to_string(), ToolErrorType::from(category), message.to_string())
534 })
535 }
536
537 #[must_use]
538 pub fn to_json_value(&self) -> Value {
539 json!({
540 "error": {
541 "tool_name": self.tool_name,
542 "error_type": self.error_type.as_str(),
543 "category": self.category,
544 "message": self.message,
545 "retryable": self.retryable,
546 "is_recoverable": self.is_recoverable,
547 "recovery_suggestions": self.recovery_suggestions,
548 "retry_delay_ms": self.retry_delay_ms,
549 "retry_after_ms": self.retry_after_ms,
550 "circuit_breaker_impact": self.circuit_breaker_impact,
551 "partial_state_possible": self.partial_state_possible,
552 "rollback_performed": self.rollback_performed,
553 "debug_context": self.debug_context,
554 "original_error": self.original_error,
555 "patch_failure": self.patch_failure,
556 "inspection_failure": self.inspection_failure,
557 "edit_failure": self.edit_failure,
558 }
559 })
560 }
561}
562
563impl std::fmt::Display for ToolExecutionError {
564 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
565 f.write_str(&self.message)
566 }
567}
568
569impl std::error::Error for ToolExecutionError {}
570
571#[inline]
574fn generate_recovery_info(
575 tool_name: &str,
576 category: ErrorCategory,
577 error_type: ToolErrorType,
578) -> (bool, bool, Vec<Cow<'static, str>>) {
579 let is_recoverable = category.is_retryable()
580 || matches!(
581 error_type,
582 ToolErrorType::InvalidParameters | ToolErrorType::PermissionDenied | ToolErrorType::ResourceNotFound
583 );
584 let retryable =
585 category.is_retryable() && !crate::retry::is_non_retryable_command_timeout(category, Some(tool_name));
586 (retryable, is_recoverable, category.recovery_suggestions())
587}
588
589fn parse_error_type(raw: &str) -> ToolErrorType {
590 match raw {
591 "InvalidParameters" => ToolErrorType::InvalidParameters,
592 "ToolNotFound" => ToolErrorType::ToolNotFound,
593 "PermissionDenied" => ToolErrorType::PermissionDenied,
594 "ResourceNotFound" => ToolErrorType::ResourceNotFound,
595 "NetworkError" => ToolErrorType::NetworkError,
596 "Timeout" => ToolErrorType::Timeout,
597 "ExecutionError" => ToolErrorType::ExecutionError,
598 "PolicyViolation" => ToolErrorType::PolicyViolation,
599 _ => ToolErrorType::ExecutionError,
600 }
601}
602
603fn format_retry_delay(delay_ms: u64) -> String {
604 if delay_ms >= 1_000 {
605 format!("{:.1}s", delay_ms as f64 / 1_000.0)
606 } else {
607 format!("{delay_ms}ms")
608 }
609}
610
611fn apply_explicit_error_state(mut error: ToolExecutionError, tool_name: &str, source: &Error) -> ToolExecutionError {
612 if tool_name != crate::config::constants::tools::APPLY_PATCH {
613 return error;
614 }
615
616 if let Some(patch_error) = source.downcast_ref::<crate::tools::editing::PatchError>() {
617 match patch_error {
618 crate::tools::editing::PatchError::RolledBack { .. } => {
619 error = error.with_partial_state(false, true);
620 }
621 crate::tools::editing::PatchError::Recovery { .. } => {
622 error = error.with_partial_state(true, false);
623 }
624 _ => {}
625 }
626 }
627
628 error
629}
630
631impl From<ErrorCategory> for ToolErrorType {
634 fn from(cat: ErrorCategory) -> Self {
635 match cat {
636 ErrorCategory::InvalidParameters => ToolErrorType::InvalidParameters,
637 ErrorCategory::ToolNotFound => ToolErrorType::ToolNotFound,
638 ErrorCategory::ResourceNotFound => ToolErrorType::ResourceNotFound,
639 ErrorCategory::PermissionDenied => ToolErrorType::PermissionDenied,
640 ErrorCategory::Network | ErrorCategory::ServiceUnavailable => ToolErrorType::NetworkError,
641 ErrorCategory::Timeout => ToolErrorType::Timeout,
642 ErrorCategory::PolicyViolation | ErrorCategory::PlanningPolicyViolation => ToolErrorType::PolicyViolation,
643 ErrorCategory::RateLimit => ToolErrorType::NetworkError,
644 ErrorCategory::CircuitOpen => ToolErrorType::ExecutionError,
645 ErrorCategory::Authentication => ToolErrorType::PermissionDenied,
646 ErrorCategory::SandboxFailure => ToolErrorType::PolicyViolation,
647 ErrorCategory::ResourceExhausted => ToolErrorType::ExecutionError,
648 ErrorCategory::Cancelled => ToolErrorType::ExecutionError,
649 ErrorCategory::ExecutionError => ToolErrorType::ExecutionError,
650 }
651 }
652}
653
654impl From<ToolErrorType> for ErrorCategory {
655 fn from(t: ToolErrorType) -> Self {
656 match t {
657 ToolErrorType::InvalidParameters => ErrorCategory::InvalidParameters,
658 ToolErrorType::ToolNotFound => ErrorCategory::ToolNotFound,
659 ToolErrorType::ResourceNotFound => ErrorCategory::ResourceNotFound,
660 ToolErrorType::PermissionDenied => ErrorCategory::PermissionDenied,
661 ToolErrorType::NetworkError => ErrorCategory::Network,
662 ToolErrorType::Timeout => ErrorCategory::Timeout,
663 ToolErrorType::PolicyViolation => ErrorCategory::PolicyViolation,
664 ToolErrorType::ExecutionError => ErrorCategory::ExecutionError,
665 }
666 }
667}
668
669#[cfg(test)]
670mod tests {
671 use super::*;
672 use anyhow::anyhow;
673
674 fn classify(err: &Error) -> ToolErrorType {
675 ToolErrorType::from(vtcode_commons::classify_anyhow_error(err))
676 }
677
678 #[test]
679 fn classify_error_marks_rate_limit_as_network_error() {
680 let err = anyhow!("provider returned 429 Too Many Requests");
681 assert!(matches!(classify(&err), ToolErrorType::NetworkError));
682 }
683
684 #[test]
685 fn classify_error_marks_service_unavailable_as_network_error() {
686 let err = anyhow!("503 Service Unavailable");
687 assert!(matches!(classify(&err), ToolErrorType::NetworkError));
688 }
689
690 #[test]
691 fn classify_error_marks_weekly_usage_limit_as_execution_error() {
692 let err = anyhow!("you have reached your weekly usage limit");
693 assert!(matches!(classify(&err), ToolErrorType::ExecutionError));
694 }
695
696 #[test]
697 fn classify_error_marks_tool_not_found() {
698 let err = anyhow!("unknown tool: ask_questions");
699 assert!(matches!(classify(&err), ToolErrorType::ToolNotFound));
700 }
701
702 #[test]
703 fn classify_error_marks_policy_violation_before_permission() {
704 let err = anyhow!("tool permission denied by policy");
705 assert!(matches!(classify(&err), ToolErrorType::PolicyViolation));
706 }
707
708 const ALL_TOOL_ERROR_TYPES: [ToolErrorType; 8] = [
709 ToolErrorType::InvalidParameters,
710 ToolErrorType::ToolNotFound,
711 ToolErrorType::PermissionDenied,
712 ToolErrorType::ResourceNotFound,
713 ToolErrorType::NetworkError,
714 ToolErrorType::Timeout,
715 ToolErrorType::ExecutionError,
716 ToolErrorType::PolicyViolation,
717 ];
718
719 #[test]
720 fn error_type_wire_string_round_trips() {
721 for error_type in ALL_TOOL_ERROR_TYPES {
722 assert_eq!(parse_error_type(error_type.as_str()), error_type);
723 }
724 }
725
726 #[test]
727 fn error_type_category_round_trip_is_identity() {
728 for error_type in ALL_TOOL_ERROR_TYPES {
731 assert_eq!(ToolErrorType::from(ErrorCategory::from(error_type)), error_type);
732 }
733 }
734
735 #[test]
736 fn from_anyhow_derives_error_type_from_category() {
737 let err = anyhow!("provider returned 429 Too Many Requests");
738 let structured = ToolExecutionError::from_anyhow("grep_search", &err, 0, false, false, None);
739 assert_eq!(structured.category, ErrorCategory::RateLimit);
740 assert_eq!(structured.error_type, ToolErrorType::from(structured.category));
741 }
742
743 #[test]
744 fn missing_exec_session_classification_survives_context_and_round_trip() {
745 let source = Error::new(crate::tools::exec_session::ExecSessionNotFound { session_id: "run-missing".into() })
746 .context("quoted diagnostic: permission denied in vtcode.toml");
747 let structured = ToolExecutionError::from_anyhow("write_stdin", &source, 0, false, false, Some("registry"));
748 assert_eq!(structured.category, ErrorCategory::ResourceNotFound);
749 assert!(structured.is_exec_session_not_found());
750 assert!(!structured.retryable);
751 assert!(!structured.circuit_breaker_impact);
752 assert_eq!(structured.debug_context.as_ref().unwrap().surface.as_deref(), Some("registry"));
753 assert!(structured.message.contains("reuse its output"));
754 assert!(!structured.message.contains("re-run the command instead of waiting"));
755 let decoded = ToolExecutionError::from_error_payload(&structured.to_json_value()).unwrap();
756 assert!(decoded.is_exec_session_not_found());
757
758 let lookalike = anyhow!("exec session 'run-missing' not found: permission denied");
759 let untyped = ToolExecutionError::from_anyhow("write_stdin", &lookalike, 0, false, false, None);
760 assert_eq!(untyped.category, ErrorCategory::PermissionDenied);
761 assert!(!untyped.is_exec_session_not_found());
762 }
763
764 #[test]
765 fn retryable_matches_error_category_predicate() {
766 for error_type in ALL_TOOL_ERROR_TYPES {
767 let category = ErrorCategory::from(error_type);
768 let structured = ToolExecutionError::new("grep_search".to_string(), error_type, "boom".to_string());
769 assert_eq!(structured.retryable, category.is_retryable(), "retryable mismatch for {error_type:?}");
770 }
771 }
772
773 #[test]
774 fn command_tool_timeouts_are_not_retryable() {
775 let structured = ToolExecutionError::new(
776 crate::config::constants::tools::CREATE_PTY_SESSION.to_string(),
777 ToolErrorType::Timeout,
778 "command timed out".to_string(),
779 );
780 assert!(!structured.retryable);
781 }
782
783 #[test]
784 fn tool_call_context_marks_mutating_tools_as_partial_state_possible() {
785 let error = ToolExecutionError::new(
786 "write_file".to_string(),
787 ToolErrorType::ExecutionError,
788 "write failed".to_string(),
789 )
790 .with_tool_call_context(
791 crate::config::constants::tools::WRITE_FILE,
792 &serde_json::json!({"path": "note.txt", "content": "hello"}),
793 );
794
795 assert!(error.partial_state_possible);
796 assert!(!error.rollback_performed);
797 }
798
799 #[test]
800 fn tool_call_context_marks_apply_patch_failures_as_rolled_back() {
801 let source = Error::new(crate::tools::editing::PatchError::RolledBack {
802 original: Box::new(crate::tools::editing::PatchError::SegmentNotFound {
803 path: "src/lib.rs".to_string(),
804 snippet: "fn main()".to_string(),
805 }),
806 });
807 let error = ToolExecutionError::from_anyhow(
808 crate::config::constants::tools::APPLY_PATCH,
809 &source,
810 0,
811 false,
812 false,
813 None,
814 );
815
816 assert!(!error.partial_state_possible);
817 assert!(error.rollback_performed);
818 }
819
820 #[test]
821 fn user_message_includes_retry_summary_and_wait() {
822 let mut error = ToolExecutionError::new(
823 crate::config::constants::tools::READ_FILE.to_string(),
824 ToolErrorType::ExecutionError,
825 "read failed".to_string(),
826 )
827 .with_attempt(2);
828 error.retry_delay_ms = Some(1_500);
829
830 let message = error.user_message();
831
832 assert!(message.contains("Retried 1 time before failing."));
833 assert!(message.contains("Recommended wait: 1.5s."));
834 }
835}