1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
//! Retry strategy trait and default exponential back-off implementation.
//!
//! The [`RetryStrategy`] trait defines the contract for deciding whether a
//! failed model call should be retried and how long to wait before the next
//! attempt. [`DefaultRetryStrategy`] provides exponential back-off with
//! optional jitter, a configurable attempt cap, and a maximum delay ceiling.
use std::time::Duration;
use crate::error::AgentError;
// ---------------------------------------------------------------------------
// Trait
// ---------------------------------------------------------------------------
/// Determines whether a failed model call should be retried and, if so, how
/// long to wait before the next attempt.
///
/// Implementations must be object-safe (`Send + Sync`) so that the strategy
/// can be stored as `Box<dyn RetryStrategy>` inside loop configuration.
pub trait RetryStrategy: Send + Sync {
/// Returns `true` if `error` on the given `attempt` number should be
/// retried. Attempt numbering starts at 1.
///
/// This is the **sole decision point** for retryability — the agent loop
/// delegates entirely to this method. Custom implementations can retry
/// any error variant (e.g., [`AgentError::Plugin`]) without being gated
/// by [`AgentError::is_retryable()`].
fn should_retry(&self, error: &AgentError, attempt: u32) -> bool;
/// Returns the duration to wait before attempt number `attempt`.
/// Attempt numbering starts at 1.
fn delay(&self, attempt: u32) -> Duration;
/// Downcast helper for type-safe access to concrete strategy types.
///
/// Used by [`AgentOptions::to_config`](crate::AgentOptions::to_config) to
/// extract serializable parameters from [`DefaultRetryStrategy`].
fn as_any(&self) -> &dyn std::any::Any;
}
// ---------------------------------------------------------------------------
// Default implementation
// ---------------------------------------------------------------------------
/// Exponential back-off retry strategy with optional jitter.
///
/// Only transient errors ([`AgentError::ModelThrottled`] and
/// [`AgentError::NetworkError`]) are retried. All other error variants are
/// considered non-retryable and cause an immediate exit.
///
/// # Defaults
///
/// | Field | Default |
/// |---|---|
/// | `max_attempts` | 3 |
/// | `base_delay` | 1 second |
/// | `max_delay` | 60 seconds |
/// | `multiplier` | 2.0 |
/// | `jitter` | `true` |
#[derive(Debug, Clone)]
pub struct DefaultRetryStrategy {
/// Maximum number of attempts (including the first). The strategy returns
/// `false` from `should_retry` once `attempt >= max_attempts`.
pub max_attempts: u32,
/// Base delay before the first retry (attempt 1).
pub base_delay: Duration,
/// Upper bound on the computed delay — the delay is capped at this value
/// regardless of the exponential growth.
pub max_delay: Duration,
/// Multiplicative factor applied per attempt.
pub multiplier: f64,
/// When `true`, the computed delay is multiplied by a random factor in
/// `[0.5, 1.5)` to spread out retries across concurrent callers.
pub jitter: bool,
}
impl Default for DefaultRetryStrategy {
fn default() -> Self {
Self {
max_attempts: 3,
base_delay: Duration::from_secs(1),
max_delay: Duration::from_secs(60),
multiplier: 2.0,
jitter: true,
}
}
}
impl DefaultRetryStrategy {
/// Set the maximum number of attempts.
#[must_use]
pub const fn with_max_attempts(mut self, n: u32) -> Self {
self.max_attempts = n;
self
}
/// Set the base delay before the first retry.
#[must_use]
pub const fn with_base_delay(mut self, d: Duration) -> Self {
self.base_delay = d;
self
}
/// Set the maximum delay cap.
#[must_use]
pub const fn with_max_delay(mut self, d: Duration) -> Self {
self.max_delay = d;
self
}
/// Set the exponential multiplier.
#[must_use]
pub const fn with_multiplier(mut self, m: f64) -> Self {
self.multiplier = m;
self
}
/// Enable or disable jitter.
#[must_use]
pub const fn with_jitter(mut self, j: bool) -> Self {
self.jitter = j;
self
}
}
impl RetryStrategy for DefaultRetryStrategy {
fn should_retry(&self, error: &AgentError, attempt: u32) -> bool {
if attempt >= self.max_attempts {
return false;
}
error.is_retryable()
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn delay(&self, attempt: u32) -> Duration {
// Exponential back-off: base_delay * multiplier^(attempt - 1)
let exp = self
.multiplier
.powi(attempt.saturating_sub(1).try_into().unwrap_or(i32::MAX));
let base_secs = self.base_delay.as_secs_f64() * exp;
// Cap at max_delay.
let capped_secs = base_secs.min(self.max_delay.as_secs_f64());
// Optionally apply jitter: multiply by a random factor in [0.5, 1.5).
let final_secs = if self.jitter {
let jitter_factor = 0.5 + rand::random::<f64>(); // [0.5, 1.5)
capped_secs * jitter_factor
} else {
capped_secs
};
Duration::from_secs_f64(final_secs)
}
}