Skip to main content

vtcode_core/
retry.rs

1//! Domain-specific retry adapters for VT Code.
2//!
3//! The retry *policy math* (attempt budgets, backoff, jitter, category-based
4//! decisions) lives in [`vtcode_commons::retry`]; this module re-exports the
5//! canonical [`RetryPolicy`] / [`RetryDecision`] and layers the workspace's
6//! domain knowledge on top via [`RetryPolicyCoreExt`]: typed error downcasts
7//! (`VtCodeError`, `LLMError`, tool errors), LLM `Retry-After` extraction,
8//! and the tool-aware command-timeout rule.
9
10use std::future::Future;
11use std::result::Result as StdResult;
12use std::time::Duration;
13
14use crate::error::{ErrorCategory, VtCodeError};
15use crate::retry_after::retry_after_from_llm_metadata;
16use crate::tools::registry::ToolExecutionError;
17use crate::tools::tool_intent::is_command_tool;
18use crate::tools::unified_error::UnifiedToolError;
19use vtcode_commons::llm::{LLMError, LLMErrorMetadata};
20use vtcode_commons::misconfiguration::detect_misconfiguration;
21
22pub use vtcode_commons::retry::{RetryDecision, RetryPolicy};
23
24/// Domain-aware retry decisions layered over the shared [`RetryPolicy`].
25///
26/// Import this trait to call the `decision_for_*` / `step_for_*` methods on
27/// a policy; the policy struct itself is defined in `vtcode-commons`.
28pub trait RetryPolicyCoreExt {
29    fn decision_for_vtcode_error(
30        &self,
31        error: &VtCodeError,
32        attempt_index: u32,
33        tool_name: Option<&str>,
34    ) -> RetryDecision;
35
36    fn decision_for_anyhow(&self, error: &anyhow::Error, attempt_index: u32, tool_name: Option<&str>) -> RetryDecision;
37
38    fn decision_for_llm_error(&self, error: &LLMError, attempt_index: u32) -> RetryDecision;
39
40    fn decision_for_tool_error(&self, error: &UnifiedToolError, attempt_index: u32) -> RetryDecision;
41
42    fn decision_for_tool_execution_error(&self, error: &ToolExecutionError, attempt_index: u32) -> RetryDecision;
43
44    /// Classify a `VtCodeError` failure into a typed [`RetryStep`].
45    ///
46    /// This consolidates the "decision -> sleep or give-up" branching used by
47    /// agent-level retry loops, removing the brittle
48    /// `decision.delay.expect("retryable decisions need delay")` calls and
49    /// guaranteeing a delay is always available for retryable steps.
50    fn step_for_vtcode_error(&self, error: VtCodeError, attempt_index: u32, tool_name: Option<&str>) -> RetryStep;
51
52    fn apply_to_tool_execution_error(
53        &self,
54        error: ToolExecutionError,
55        attempt_index: u32,
56        tool_name: Option<&str>,
57    ) -> ToolExecutionError;
58}
59
60impl RetryPolicyCoreExt for RetryPolicy {
61    fn decision_for_vtcode_error(
62        &self,
63        error: &VtCodeError,
64        attempt_index: u32,
65        tool_name: Option<&str>,
66    ) -> RetryDecision {
67        // Misconfiguration-first: never retry user-fixable config failures.
68        if error.is_misconfiguration() {
69            return misconfiguration_decision(error.category, error.retry_after());
70        }
71        decision_for_category_with_tool(self, error.category, attempt_index, error.retry_after(), tool_name)
72    }
73
74    fn decision_for_anyhow(&self, error: &anyhow::Error, attempt_index: u32, tool_name: Option<&str>) -> RetryDecision {
75        if let Some(vtcode_error) = error.downcast_ref::<VtCodeError>() {
76            return self.decision_for_vtcode_error(vtcode_error, attempt_index, tool_name);
77        }
78        if let Some(llm_error) = error.downcast_ref::<LLMError>() {
79            return self.decision_for_llm_error(llm_error, attempt_index);
80        }
81        if let Some(tool_error) = error.downcast_ref::<UnifiedToolError>() {
82            let category = tool_error.category();
83            if detect_misconfiguration(category, &unified_tool_text(tool_error)).is_some() {
84                return misconfiguration_decision(category, None);
85            }
86            // Preserve fallback tool name for command-timeout rule.
87            let effective_tool = tool_name.or_else(|| {
88                tool_error
89                    .debug_context
90                    .as_ref()
91                    .map(|ctx| ctx.tool_name.as_str())
92                    .filter(|name| !name.is_empty())
93            });
94            return decision_for_category_with_tool(self, category, attempt_index, None, effective_tool);
95        }
96
97        let category = vtcode_commons::classify_anyhow_error(error);
98        let message = format!("{error:#}");
99        if detect_misconfiguration(category, &message).is_some() {
100            return misconfiguration_decision(category, None);
101        }
102        decision_for_category_with_tool(self, category, attempt_index, None, tool_name)
103    }
104
105    fn decision_for_llm_error(&self, error: &LLMError, attempt_index: u32) -> RetryDecision {
106        let retry_after = llm_metadata(error).and_then(retry_after_from_llm_metadata);
107        let category = ErrorCategory::from(error);
108        if vtcode_commons::detect_misconfiguration_in_llm_error(error).is_some() {
109            return misconfiguration_decision(category, retry_after);
110        }
111        decision_for_category_with_tool(self, category, attempt_index, retry_after, None)
112    }
113
114    fn decision_for_tool_error(&self, error: &UnifiedToolError, attempt_index: u32) -> RetryDecision {
115        let category = error.category();
116        if detect_misconfiguration(category, &unified_tool_text(error)).is_some() {
117            return misconfiguration_decision(category, None);
118        }
119        let tool_name = error
120            .debug_context
121            .as_ref()
122            .map(|ctx| ctx.tool_name.as_str())
123            .filter(|tool_name| !tool_name.is_empty());
124        decision_for_category_with_tool(self, category, attempt_index, None, tool_name)
125    }
126
127    fn decision_for_tool_execution_error(&self, error: &ToolExecutionError, attempt_index: u32) -> RetryDecision {
128        if error.patch_failure.is_some() {
129            return RetryDecision {
130                category: error.category,
131                retryable: false,
132                delay: None,
133                retry_after: None,
134            };
135        }
136        if detect_misconfiguration(error.category, &tool_execution_text(error)).is_some() {
137            return misconfiguration_decision(error.category, error.retry_after());
138        }
139        decision_for_category_with_tool(
140            self,
141            error.category,
142            attempt_index,
143            error.retry_after(),
144            Some(error.tool_name.as_str()),
145        )
146    }
147
148    fn step_for_vtcode_error(&self, error: VtCodeError, attempt_index: u32, tool_name: Option<&str>) -> RetryStep {
149        // Attach config guidance first so the surfaced error guides the user
150        // before any retry/backoff decision.
151        let error = error.with_misconfiguration_guidance();
152        let decision = self.decision_for_vtcode_error(&error, attempt_index, tool_name);
153        if decision.retryable {
154            let delay = decision.delay.unwrap_or_else(|| self.delay_for_attempt(attempt_index));
155            RetryStep::Backoff { delay, decision, error }
156        } else {
157            RetryStep::GiveUp { decision, error }
158        }
159    }
160
161    fn apply_to_tool_execution_error(
162        &self,
163        error: ToolExecutionError,
164        attempt_index: u32,
165        tool_name: Option<&str>,
166    ) -> ToolExecutionError {
167        if error.patch_failure.is_some() {
168            return error;
169        }
170        if let Some(guidance) = detect_misconfiguration(error.category, &tool_execution_text(&error)) {
171            let decision = misconfiguration_decision(error.category, error.retry_after());
172            let mut guided = error.with_retry_decision(decision);
173            // Prepend config guidance so the user sees settings/config first.
174            let mut suggestions = vec![std::borrow::Cow::Owned(guidance.user_message())];
175            suggestions.append(&mut guided.recovery_suggestions);
176            guided.recovery_suggestions = suggestions;
177            guided.retryable = false;
178            guided.is_recoverable = false;
179            guided.circuit_breaker_impact = false;
180            return guided;
181        }
182        let decision = decision_for_category_with_tool(
183            self,
184            error.category,
185            attempt_index,
186            error.retry_after(),
187            tool_name.or(Some(error.tool_name.as_str())),
188        );
189        error.with_retry_decision(decision)
190    }
191}
192
193fn decision_for_category_with_tool(
194    policy: &RetryPolicy,
195    category: ErrorCategory,
196    attempt_index: u32,
197    retry_after: Option<Duration>,
198    tool_name: Option<&str>,
199) -> RetryDecision {
200    if is_non_retryable_command_timeout(category, tool_name) {
201        return RetryDecision {
202            category,
203            retryable: false,
204            delay: None,
205            retry_after,
206        };
207    }
208
209    policy.decision_for_category(category, attempt_index, retry_after)
210}
211
212pub(crate) fn category_was_retryable(error: &VtCodeError) -> bool {
213    error.category.is_retryable() && !error.is_misconfiguration()
214}
215
216/// Fail-closed decision for user misconfiguration: never retry, preserve
217/// any `Retry-After` hint for diagnostics but force immediate `GiveUp`.
218fn misconfiguration_decision(category: ErrorCategory, retry_after: Option<Duration>) -> RetryDecision {
219    RetryDecision {
220        category,
221        retryable: false,
222        delay: None,
223        retry_after,
224    }
225}
226
227/// The single tool-aware retry rule: command tool timeouts are never
228/// retryable because the underlying process may still be running, so a
229/// retry can contend for locks or duplicate side effects.
230pub(crate) fn is_non_retryable_command_timeout(category: ErrorCategory, tool_name: Option<&str>) -> bool {
231    matches!(category, ErrorCategory::Timeout) && tool_name.is_some_and(is_command_tool)
232}
233
234fn unified_tool_text(error: &UnifiedToolError) -> String {
235    let mut text = error.user_message.clone();
236    if let Some(source) = error.source.as_ref() {
237        text.push('\n');
238        text.push_str(&format!("{source:#}"));
239    }
240    text
241}
242
243fn tool_execution_text(error: &ToolExecutionError) -> String {
244    let mut text = error.message.clone();
245    if let Some(original) = error.original_error.as_deref() {
246        text.push('\n');
247        text.push_str(original);
248    }
249    text
250}
251
252/// Typed step produced by [`RetryPolicyCoreExt::step_for_vtcode_error`].
253///
254/// Callers match on this instead of re-deriving the
255/// `if decision.retryable { sleep(decision.delay.expect(...)) }` pattern.
256#[derive(Debug)]
257pub enum RetryStep {
258    /// Wait `delay` then retry; `error` is the failure being retried.
259    Backoff {
260        delay: Duration,
261        decision: RetryDecision,
262        error: VtCodeError,
263    },
264    /// Give up immediately and surface `error`.
265    GiveUp {
266        decision: RetryDecision,
267        error: VtCodeError,
268    },
269}
270
271/// Lifecycle event emitted by [`run_with_retry`] so callers can attach
272/// logging, metrics, or stats updates without re-implementing the loop.
273///
274/// `category_was_retryable` is provided alongside `GiveUp` and `Backoff`
275/// because call sites frequently need to distinguish "non-retryable
276/// category" from "retryable category, budget exhausted" — the distinction
277/// is otherwise invisible once `step_for_vtcode_error` collapses both
278/// into `GiveUp`.
279#[derive(Debug)]
280pub enum RetryEvent<'a> {
281    /// An attempt is about to start. `attempt` is 0-indexed.
282    AttemptStart { attempt: u32, max_attempts: u32 },
283    /// The operation succeeded on the given attempt.
284    Success { attempt: u32 },
285    /// The policy decided to give up and surface `error` immediately.
286    GiveUp {
287        attempt: u32,
288        error: &'a VtCodeError,
289        decision: &'a RetryDecision,
290        category_was_retryable: bool,
291    },
292    /// The policy decided to back off and retry. The driver will sleep
293    /// for `delay` before invoking the operation again.
294    Backoff {
295        attempt: u32,
296        error: &'a VtCodeError,
297        decision: &'a RetryDecision,
298        delay: Duration,
299        category_was_retryable: bool,
300    },
301    /// All attempts were exhausted. `last_error` is the most recent
302    /// error captured from a `Backoff` step.
303    Exhausted { last_error: Option<&'a VtCodeError> },
304}
305
306/// Drive a retry loop per `policy`, invoking `on_event` for each
307/// lifecycle event. Returns the first successful result, or the final
308/// error if the policy gives up or all attempts are exhausted.
309///
310/// `state` is reborrowed mutably and passed to each callback on every
311/// invocation, so callers can use it to thread a `&mut self` (or any
312/// other mutable context) through the loop without resorting to
313/// `RefCell` or split borrows. The `operation` callback must return a
314/// boxed future so the helper can `await` it without tying its lifetime
315/// to the closure's own borrow of `state`.
316///
317/// The returned future is `Send` so it can be passed to `tokio::spawn`.
318///
319/// `synthesize_exhausted_error` is invoked only in the (degenerate)
320/// case where the loop completes without ever recording a `Backoff`
321/// error. The closure receives the `&RetryPolicy` so it can attach
322/// site-specific context (e.g. `policy.max_attempts`) without having
323/// to capture the policy in its environment — which is exactly the
324/// pattern that conflicts with `&mut state`.
325#[allow(
326    clippy::too_many_arguments,
327    reason = "Intentional compatibility, platform, or test-only suppression."
328)]
329pub async fn run_with_retry<T, E, S, F, OnEvent, Synthesize>(
330    policy: &RetryPolicy,
331    state: &mut S,
332    mut on_event: OnEvent,
333    mut operation: F,
334    synthesize_exhausted_error: Synthesize,
335) -> crate::error::Result<T>
336where
337    F: for<'a> FnMut(&'a mut S) -> std::pin::Pin<Box<dyn Future<Output = StdResult<T, E>> + Send + 'a>>,
338    E: Into<VtCodeError>,
339    OnEvent: FnMut(&mut S, RetryEvent<'_>),
340    Synthesize: FnOnce(&RetryPolicy) -> VtCodeError,
341{
342    use tokio::time::sleep;
343
344    let mut last_error: Option<VtCodeError> = None;
345    for attempt in 0..policy.max_attempts {
346        on_event(state, RetryEvent::AttemptStart { attempt, max_attempts: policy.max_attempts });
347        match operation(state).await {
348            Ok(value) => {
349                on_event(state, RetryEvent::Success { attempt });
350                return Ok(value);
351            }
352            Err(err) => {
353                let err: VtCodeError = err.into();
354                let category_was_retryable = category_was_retryable(&err);
355                let step = policy.step_for_vtcode_error(err, attempt, None);
356                match step {
357                    RetryStep::GiveUp { decision, error } => {
358                        on_event(
359                            state,
360                            RetryEvent::GiveUp {
361                                attempt,
362                                error: &error,
363                                decision: &decision,
364                                category_was_retryable,
365                            },
366                        );
367                        return Err(error);
368                    }
369                    RetryStep::Backoff { delay, decision, error } => {
370                        on_event(
371                            state,
372                            RetryEvent::Backoff {
373                                attempt,
374                                error: &error,
375                                decision: &decision,
376                                delay,
377                                category_was_retryable,
378                            },
379                        );
380                        last_error = Some(error);
381                        sleep(delay).await;
382                    }
383                }
384            }
385        }
386    }
387    let final_error = last_error.unwrap_or_else(|| synthesize_exhausted_error(policy));
388    on_event(state, RetryEvent::Exhausted { last_error: Some(&final_error) });
389    Err(final_error)
390}
391
392fn llm_metadata(error: &LLMError) -> Option<&LLMErrorMetadata> {
393    match error {
394        LLMError::Authentication { metadata, .. }
395        | LLMError::RateLimit { metadata }
396        | LLMError::InvalidRequest { metadata, .. }
397        | LLMError::Network { metadata, .. }
398        | LLMError::Provider { metadata, .. } => metadata.as_deref(),
399    }
400}
401
402pub fn decision_for_vtcode_error(
403    error: &VtCodeError,
404    attempt_index: u32,
405    tool_name: Option<&str>,
406    policy_override: Option<&RetryPolicy>,
407) -> RetryDecision {
408    let policy = policy_override.unwrap_or(&RetryPolicy::DEFAULT);
409    policy.decision_for_vtcode_error(error, attempt_index, tool_name)
410}
411
412pub fn decision_for_anyhow_error(
413    error: &anyhow::Error,
414    attempt_index: u32,
415    tool_name: Option<&str>,
416    policy_override: Option<&RetryPolicy>,
417) -> RetryDecision {
418    let policy = policy_override.unwrap_or(&RetryPolicy::DEFAULT);
419    policy.decision_for_anyhow(error, attempt_index, tool_name)
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425    use crate::config::constants::tools;
426    use crate::error::{ErrorCode, VtCodeError};
427
428    #[test]
429    fn non_retryable_categories_stop_immediately() {
430        let policy = RetryPolicy::from_retries(2, Duration::from_secs(1), Duration::from_secs(8), 2.0);
431        let err = VtCodeError::security(ErrorCode::PermissionDenied, "blocked by policy");
432
433        let decision = policy.decision_for_vtcode_error(&err, 0, None);
434        assert_eq!(decision.category, ErrorCategory::PolicyViolation);
435        assert!(!decision.retryable);
436        assert!(decision.delay.is_none());
437    }
438
439    #[test]
440    fn retry_after_header_overrides_backoff_delay() {
441        let policy = RetryPolicy::from_retries(3, Duration::from_secs(1), Duration::from_secs(8), 2.0);
442        let err = LLMError::RateLimit {
443            metadata: Some(LLMErrorMetadata::new(
444                "Anthropic",
445                Some(429),
446                Some("rate_limit_error".to_string()),
447                None,
448                None,
449                Some("7".to_string()),
450                Some("too many requests".to_string()),
451            )),
452        };
453
454        let decision = policy.decision_for_llm_error(&err, 0);
455        assert!(decision.retryable);
456        assert_eq!(decision.retry_after, Some(Duration::from_secs(7)));
457        assert_eq!(decision.delay, Some(Duration::from_secs(7)));
458    }
459
460    #[test]
461    fn quota_exhaustion_is_not_retryable() {
462        let policy = RetryPolicy::from_retries(3, Duration::from_secs(1), Duration::from_secs(8), 2.0);
463        let err = LLMError::RateLimit {
464            metadata: Some(LLMErrorMetadata::new(
465                "OpenAI",
466                Some(429),
467                Some("insufficient_quota".to_string()),
468                None,
469                None,
470                None,
471                Some("quota exceeded".to_string()),
472            )),
473        };
474
475        let decision = policy.decision_for_llm_error(&err, 0);
476        assert_eq!(decision.category, ErrorCategory::ResourceExhausted);
477        assert!(!decision.retryable);
478    }
479
480    #[test]
481    fn anyhow_fallback_uses_shared_classifier() {
482        let policy = RetryPolicy::from_retries(1, Duration::from_secs(1), Duration::from_secs(8), 2.0);
483
484        let decision = policy.decision_for_anyhow(&anyhow::anyhow!("HTTP 503 Service Unavailable"), 0, None);
485        assert_eq!(decision.category, ErrorCategory::ServiceUnavailable);
486        assert!(decision.retryable);
487        assert_eq!(decision.delay, Some(Duration::from_secs(1)));
488    }
489
490    #[test]
491    fn anyhow_prefers_typed_llm_errors() {
492        let policy = RetryPolicy::from_retries(3, Duration::from_secs(1), Duration::from_secs(8), 2.0);
493        let err = anyhow::Error::new(LLMError::RateLimit {
494            metadata: Some(LLMErrorMetadata::new(
495                "Anthropic",
496                Some(429),
497                Some("rate_limit_error".to_string()),
498                None,
499                None,
500                Some("9".to_string()),
501                Some("too many requests".to_string()),
502            )),
503        });
504
505        let decision = policy.decision_for_anyhow(&err, 0, None);
506        assert!(decision.retryable);
507        assert_eq!(decision.retry_after, Some(Duration::from_secs(9)));
508        assert_eq!(decision.delay, Some(Duration::from_secs(9)));
509    }
510
511    #[test]
512    fn canonical_exec_aliases_are_command_tools() {
513        for alias in [
514            tools::RUN_PTY_CMD,
515            tools::EXEC_COMMAND,
516            tools::WRITE_STDIN,
517            tools::UNIFIED_EXEC,
518            "shell",
519            "bash",
520            "container.exec",
521        ] {
522            assert!(is_command_tool(alias), "expected {alias} to be a command tool");
523        }
524    }
525
526    #[test]
527    fn typed_tool_timeout_for_command_tools_is_not_retryable() {
528        let policy = RetryPolicy::from_retries(2, Duration::from_secs(1), Duration::from_secs(8), 2.0);
529        let err = UnifiedToolError::new(crate::tools::unified_error::UnifiedErrorKind::Timeout, "timed out")
530            .with_tool_name(tools::RUN_PTY_CMD);
531
532        let decision = policy.decision_for_tool_error(&err, 0);
533        assert_eq!(decision.category, ErrorCategory::Timeout);
534        assert!(!decision.retryable);
535    }
536
537    #[test]
538    fn anyhow_typed_tool_timeout_uses_fallback_tool_name() {
539        let policy = RetryPolicy::from_retries(2, Duration::from_secs(1), Duration::from_secs(8), 2.0);
540        let err = anyhow::Error::new(UnifiedToolError::new(
541            crate::tools::unified_error::UnifiedErrorKind::Timeout,
542            "timed out",
543        ));
544
545        let decision = policy.decision_for_anyhow(&err, 0, Some(tools::RUN_PTY_CMD));
546        assert_eq!(decision.category, ErrorCategory::Timeout);
547        assert!(!decision.retryable);
548    }
549
550    #[test]
551    fn command_timeouts_do_not_retry() {
552        let policy = RetryPolicy::from_retries(2, Duration::from_secs(1), Duration::from_secs(8), 2.0);
553        let err = VtCodeError::new(ErrorCategory::Timeout, ErrorCode::Timeout, "timed out");
554
555        let decision = policy.decision_for_vtcode_error(&err, 0, Some(tools::RUN_PTY_CMD));
556        assert_eq!(decision.category, ErrorCategory::Timeout);
557        assert!(!decision.retryable);
558    }
559
560    #[tokio::test]
561    async fn run_with_retry_returns_first_success() {
562        use std::sync::Arc;
563        use std::sync::atomic::{AtomicU32, Ordering};
564        let policy = RetryPolicy::from_retries(3, Duration::from_millis(0), Duration::from_millis(1), 2.0);
565        let attempts = Arc::new(AtomicU32::new(0));
566        let attempts_for_op = attempts.clone();
567        let result: crate::error::Result<String> = run_with_retry(
568            &policy,
569            &mut (),
570            |_: &mut (), _| {},
571            |_| {
572                let attempts = attempts_for_op.clone();
573                Box::pin(async move {
574                    let n = attempts.fetch_add(1, Ordering::SeqCst) + 1;
575                    if n < 2 {
576                        Err(VtCodeError::network(ErrorCode::ConnectionFailed, "transient"))
577                    } else {
578                        Ok("ok".to_string())
579                    }
580                })
581            },
582            |_: &RetryPolicy| VtCodeError::execution(ErrorCode::ToolExecutionFailed, "exhausted"),
583        )
584        .await;
585        assert_eq!(result.unwrap(), "ok");
586        assert_eq!(attempts.load(Ordering::SeqCst), 2);
587    }
588
589    #[tokio::test]
590    async fn run_with_retry_surfaces_give_up_immediately() {
591        use std::sync::Arc;
592        use std::sync::atomic::{AtomicU32, Ordering};
593        let policy = RetryPolicy::from_retries(5, Duration::from_millis(0), Duration::from_millis(1), 2.0);
594        let attempts = Arc::new(AtomicU32::new(0));
595        let attempts_for_op = attempts.clone();
596        let result: crate::error::Result<String> = run_with_retry(
597            &policy,
598            &mut (),
599            |_: &mut (), _| {},
600            |_| {
601                let attempts = attempts_for_op.clone();
602                Box::pin(async move {
603                    attempts.fetch_add(1, Ordering::SeqCst);
604                    Err::<String, _>(VtCodeError::input(ErrorCode::InvalidArgument, "bad input"))
605                })
606            },
607            |_: &RetryPolicy| VtCodeError::execution(ErrorCode::ToolExecutionFailed, "exhausted"),
608        )
609        .await;
610        assert!(result.is_err());
611        assert_eq!(attempts.load(Ordering::SeqCst), 1, "GiveUp should short-circuit retries");
612    }
613
614    #[test]
615    fn misconfiguration_fails_fast_with_guidance() {
616        let policy = RetryPolicy::from_retries(5, Duration::from_secs(1), Duration::from_secs(8), 2.0);
617        let err = VtCodeError::new(
618            ErrorCategory::Authentication,
619            ErrorCode::AuthenticationFailed,
620            "Authentication failed: invalid api key",
621        );
622
623        let decision = policy.decision_for_vtcode_error(&err, 0, None);
624        assert!(!decision.retryable);
625        assert!(decision.delay.is_none());
626
627        let step = policy.step_for_vtcode_error(err, 0, None);
628        match step {
629            RetryStep::GiveUp { error, .. } => {
630                assert!(error.message.contains("Check settings/config first"));
631                assert!(error.message.contains("before retrying"));
632            }
633            RetryStep::Backoff { .. } => panic!("misconfiguration must not back off"),
634        }
635    }
636
637    #[test]
638    fn misconfiguration_model_error_does_not_retry() {
639        let policy = RetryPolicy::from_retries(5, Duration::from_secs(1), Duration::from_secs(8), 2.0);
640        let err = VtCodeError::input(ErrorCode::InvalidArgument, "unknown model 'gpt-99' in agent.model");
641
642        assert!(err.is_misconfiguration());
643        let decision = policy.decision_for_vtcode_error(&err, 0, None);
644        assert!(!decision.retryable);
645    }
646
647    #[test]
648    fn transient_errors_still_retry_without_guidance() {
649        let policy = RetryPolicy::from_retries(3, Duration::from_secs(1), Duration::from_secs(8), 2.0);
650        let err = VtCodeError::network(ErrorCode::ConnectionFailed, "connection reset by peer");
651
652        assert!(!err.is_misconfiguration());
653        let decision = policy.decision_for_vtcode_error(&err, 0, None);
654        assert!(decision.retryable);
655    }
656
657    #[test]
658    fn misconfiguration_does_not_count_as_retryable_category() {
659        let err = VtCodeError::network(ErrorCode::ConnectionFailed, "invalid provider_overrides configuration");
660
661        assert!(err.is_misconfiguration());
662        assert!(!category_was_retryable(&err));
663    }
664
665    #[test]
666    fn llm_auth_error_is_misconfiguration() {
667        let policy = RetryPolicy::from_retries(3, Duration::from_secs(1), Duration::from_secs(8), 2.0);
668        let err = LLMError::Authentication {
669            message: "invalid api key".to_string(),
670            metadata: None,
671        };
672
673        let decision = policy.decision_for_llm_error(&err, 0);
674        assert!(!decision.retryable);
675        assert_eq!(decision.category, ErrorCategory::Authentication);
676    }
677
678    #[test]
679    fn tool_execution_misconfiguration_prepends_guidance() {
680        use crate::tools::registry::ToolErrorType;
681
682        let policy = RetryPolicy::from_retries(3, Duration::from_secs(1), Duration::from_secs(8), 2.0);
683        let err = ToolExecutionError::new(
684            "test_tool".to_string(),
685            ToolErrorType::ExecutionError,
686            "unknown model 'foo' in agent.model".to_string(),
687        );
688
689        let guided = policy.apply_to_tool_execution_error(err, 0, None);
690        assert!(!guided.retryable);
691        assert!(!guided.is_recoverable);
692        assert!(!guided.circuit_breaker_impact);
693        assert!(
694            guided
695                .recovery_suggestions
696                .first()
697                .is_some_and(|s| s.contains("Check settings/config first"))
698        );
699    }
700}