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
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
//! Error types for the swink agent.
//!
//! All error conditions surfaced to the caller are represented as variants of
//! [`AgentError`]. Transient failures (`ModelThrottled`, `NetworkError`) are
//! retryable by the default strategy; all other variants are terminal for the
//! current operation unless a custom retry strategy opts into retrying them.
/// Error returned when downcasting an [`AgentMessage`](crate::types::AgentMessage) to a concrete
/// custom message type fails.
#[derive(Debug)]
pub struct DowncastError {
/// The expected (target) type name.
pub expected: &'static str,
/// The actual type description found.
pub actual: String,
}
impl std::fmt::Display for DowncastError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Downcast failed: expected {}, got {}",
self.expected, self.actual
)
}
}
impl std::error::Error for DowncastError {}
/// The top-level error type for the swink agent.
///
/// Each variant maps to a specific failure mode described in PRD section 10.3.
#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum AgentError {
/// Provider rejected the request because input exceeds the model's context window.
#[error("context window overflow for model: {model}")]
ContextWindowOverflow { model: String },
/// Rate limit / 429 received from the provider.
#[error("model request throttled (rate limited)")]
ModelThrottled,
/// Transient IO or connection failure.
#[error("network error")]
NetworkError {
#[source]
source: Box<dyn std::error::Error + Send + Sync>,
},
/// Structured output validation failed after exhausting all retry attempts.
#[error("structured output failed after {attempts} attempts: {last_error}")]
StructuredOutputFailed { attempts: usize, last_error: String },
/// `prompt()` was called while a run is already active.
#[error("agent is already running")]
AlreadyRunning,
/// `continue_loop()` was called with an empty message history.
#[error("cannot continue with empty message history")]
NoMessages,
/// `continue_loop()` was called when the last message is an assistant message.
#[error("cannot continue when last message is an assistant message")]
InvalidContinue,
/// Non-retryable failure from the `StreamFn` implementation.
#[error("stream error")]
StreamError {
#[source]
source: Box<dyn std::error::Error + Send + Sync>,
},
/// The operation was cancelled via a `CancellationToken`.
#[error("operation aborted via cancellation token")]
Aborted,
/// An error from a plugin or extension.
#[error("plugin error ({name})")]
Plugin {
name: String,
source: Box<dyn std::error::Error + Send + Sync>,
},
/// Provider-side context cache was not found (evicted or expired).
///
/// The framework resets [`CacheState`](crate::context_cache::CacheState)
/// before consulting the configured retry strategy. Custom strategies can
/// choose to retry with `CacheHint::Write`.
#[error("provider cache miss")]
CacheMiss,
/// Provider safety / content filter blocked the response.
///
/// Non-retryable — the input triggered a provider-side content policy.
/// Callers can match on this variant to distinguish safety blocks from
/// auth or network errors.
#[error("content filtered by provider safety policy")]
ContentFiltered,
/// A synchronous API (`prompt_sync`, `continue_sync`, etc.) was called
/// from within an active Tokio runtime.
///
/// These methods create their own Tokio runtime internally. Calling them
/// from async code (or any thread that already has a Tokio runtime) would
/// panic. Use the `_async` or `_stream` variants instead.
#[error("sync API called inside an active Tokio runtime — use the async variant instead")]
SyncInAsyncContext,
/// The internal Tokio runtime used by blocking sync APIs failed to start.
#[error("failed to create Tokio runtime for sync API")]
RuntimeInit {
#[source]
source: std::io::Error,
},
}
impl AgentError {
/// Returns `true` for error variants that are safe to retry by default
/// (`ModelThrottled` and `NetworkError`).
#[must_use]
pub const fn is_retryable(&self) -> bool {
matches!(self, Self::ModelThrottled | Self::NetworkError { .. })
}
/// Convenience constructor for [`AgentError::NetworkError`].
pub fn network(err: impl std::error::Error + Send + Sync + 'static) -> Self {
Self::NetworkError {
source: Box::new(err),
}
}
/// Convenience constructor for [`AgentError::StreamError`].
pub fn stream(err: impl std::error::Error + Send + Sync + 'static) -> Self {
Self::StreamError {
source: Box::new(err),
}
}
/// Convenience constructor for [`AgentError::ContextWindowOverflow`].
pub fn context_overflow(model: impl Into<String>) -> Self {
Self::ContextWindowOverflow {
model: model.into(),
}
}
/// Convenience constructor for [`AgentError::StructuredOutputFailed`].
pub fn structured_output_failed(attempts: usize, last_error: impl Into<String>) -> Self {
Self::StructuredOutputFailed {
attempts,
last_error: last_error.into(),
}
}
/// Convenience constructor for [`AgentError::Plugin`].
pub fn plugin(
name: impl Into<String>,
source: impl std::error::Error + Send + Sync + 'static,
) -> Self {
Self::Plugin {
name: name.into(),
source: Box::new(source),
}
}
/// Convenience constructor for [`AgentError::RuntimeInit`].
pub const fn runtime_init(source: std::io::Error) -> Self {
Self::RuntimeInit { source }
}
}
impl From<std::io::Error> for AgentError {
fn from(err: std::io::Error) -> Self {
Self::NetworkError {
source: Box::new(err),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn agent_error_plugin_display() {
let err = AgentError::plugin("my-plugin", std::io::Error::other("boom"));
let msg = format!("{err}");
assert_eq!(msg, "plugin error (my-plugin)");
}
#[test]
fn plugin_error_not_retryable() {
let err = AgentError::plugin("test", std::io::Error::other("fail"));
assert!(!err.is_retryable());
}
#[test]
fn content_filtered_not_retryable() {
let err = AgentError::ContentFiltered;
assert!(!err.is_retryable());
assert_eq!(
format!("{err}"),
"content filtered by provider safety policy"
);
}
#[test]
fn sync_in_async_context_not_retryable() {
let err = AgentError::SyncInAsyncContext;
assert!(!err.is_retryable());
assert!(format!("{err}").contains("sync API"));
}
}