Skip to main content

swink_agent/
error.rs

1//! Error types for the swink agent.
2//!
3//! All error conditions surfaced to the caller are represented as variants of
4//! [`AgentError`]. Transient failures (`ModelThrottled`, `NetworkError`) are
5//! retryable by the default strategy; all other variants are terminal for the
6//! current operation unless a custom retry strategy opts into retrying them.
7
8/// Error returned when downcasting an [`AgentMessage`](crate::types::AgentMessage) to a concrete
9/// custom message type fails.
10#[derive(Debug)]
11pub struct DowncastError {
12    /// The expected (target) type name.
13    pub expected: &'static str,
14    /// The actual type description found.
15    pub actual: String,
16}
17
18impl std::fmt::Display for DowncastError {
19    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20        write!(
21            f,
22            "Downcast failed: expected {}, got {}",
23            self.expected, self.actual
24        )
25    }
26}
27
28impl std::error::Error for DowncastError {}
29
30/// The top-level error type for the swink agent.
31///
32/// Each variant maps to a specific failure mode described in PRD section 10.3.
33#[non_exhaustive]
34#[derive(Debug, thiserror::Error)]
35pub enum AgentError {
36    /// Provider rejected the request because input exceeds the model's context window.
37    #[error("context window overflow for model: {model}")]
38    ContextWindowOverflow { model: String },
39
40    /// Rate limit / 429 received from the provider.
41    #[error("model request throttled (rate limited)")]
42    ModelThrottled,
43
44    /// Transient IO or connection failure.
45    #[error("network error")]
46    NetworkError {
47        #[source]
48        source: Box<dyn std::error::Error + Send + Sync>,
49    },
50
51    /// Structured output validation failed after exhausting all retry attempts.
52    #[error("structured output failed after {attempts} attempts: {last_error}")]
53    StructuredOutputFailed { attempts: usize, last_error: String },
54
55    /// `prompt()` was called while a run is already active.
56    #[error("agent is already running")]
57    AlreadyRunning,
58
59    /// `continue_loop()` was called with an empty message history.
60    #[error("cannot continue with empty message history")]
61    NoMessages,
62
63    /// `continue_loop()` was called when the last message is an assistant message.
64    #[error("cannot continue when last message is an assistant message")]
65    InvalidContinue,
66
67    /// Non-retryable failure from the `StreamFn` implementation.
68    #[error("stream error")]
69    StreamError {
70        #[source]
71        source: Box<dyn std::error::Error + Send + Sync>,
72    },
73
74    /// The operation was cancelled via a `CancellationToken`.
75    #[error("operation aborted via cancellation token")]
76    Aborted,
77
78    /// An error from a plugin or extension.
79    #[error("plugin error ({name})")]
80    Plugin {
81        name: String,
82        source: Box<dyn std::error::Error + Send + Sync>,
83    },
84
85    /// Provider-side context cache was not found (evicted or expired).
86    ///
87    /// The framework resets [`CacheState`](crate::context_cache::CacheState)
88    /// before consulting the configured retry strategy. Custom strategies can
89    /// choose to retry with `CacheHint::Write`.
90    #[error("provider cache miss")]
91    CacheMiss,
92
93    /// Provider safety / content filter blocked the response.
94    ///
95    /// Non-retryable — the input triggered a provider-side content policy.
96    /// Callers can match on this variant to distinguish safety blocks from
97    /// auth or network errors.
98    #[error("content filtered by provider safety policy")]
99    ContentFiltered,
100
101    /// A synchronous API (`prompt_sync`, `continue_sync`, etc.) was called
102    /// from within an active Tokio runtime.
103    ///
104    /// These methods create their own Tokio runtime internally.  Calling them
105    /// from async code (or any thread that already has a Tokio runtime) would
106    /// panic.  Use the `_async` or `_stream` variants instead.
107    #[error("sync API called inside an active Tokio runtime — use the async variant instead")]
108    SyncInAsyncContext,
109
110    /// The internal Tokio runtime used by blocking sync APIs failed to start.
111    #[error("failed to create Tokio runtime for sync API")]
112    RuntimeInit {
113        #[source]
114        source: std::io::Error,
115    },
116}
117
118impl AgentError {
119    /// Returns `true` for error variants that are safe to retry by default
120    /// (`ModelThrottled` and `NetworkError`).
121    #[must_use]
122    pub const fn is_retryable(&self) -> bool {
123        matches!(self, Self::ModelThrottled | Self::NetworkError { .. })
124    }
125
126    /// Convenience constructor for [`AgentError::NetworkError`].
127    pub fn network(err: impl std::error::Error + Send + Sync + 'static) -> Self {
128        Self::NetworkError {
129            source: Box::new(err),
130        }
131    }
132
133    /// Convenience constructor for [`AgentError::StreamError`].
134    pub fn stream(err: impl std::error::Error + Send + Sync + 'static) -> Self {
135        Self::StreamError {
136            source: Box::new(err),
137        }
138    }
139
140    /// Convenience constructor for [`AgentError::ContextWindowOverflow`].
141    pub fn context_overflow(model: impl Into<String>) -> Self {
142        Self::ContextWindowOverflow {
143            model: model.into(),
144        }
145    }
146
147    /// Convenience constructor for [`AgentError::StructuredOutputFailed`].
148    pub fn structured_output_failed(attempts: usize, last_error: impl Into<String>) -> Self {
149        Self::StructuredOutputFailed {
150            attempts,
151            last_error: last_error.into(),
152        }
153    }
154
155    /// Convenience constructor for [`AgentError::Plugin`].
156    pub fn plugin(
157        name: impl Into<String>,
158        source: impl std::error::Error + Send + Sync + 'static,
159    ) -> Self {
160        Self::Plugin {
161            name: name.into(),
162            source: Box::new(source),
163        }
164    }
165
166    /// Convenience constructor for [`AgentError::RuntimeInit`].
167    pub const fn runtime_init(source: std::io::Error) -> Self {
168        Self::RuntimeInit { source }
169    }
170}
171
172impl From<std::io::Error> for AgentError {
173    fn from(err: std::io::Error) -> Self {
174        Self::NetworkError {
175            source: Box::new(err),
176        }
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn agent_error_plugin_display() {
186        let err = AgentError::plugin("my-plugin", std::io::Error::other("boom"));
187        let msg = format!("{err}");
188        assert_eq!(msg, "plugin error (my-plugin)");
189    }
190
191    #[test]
192    fn plugin_error_not_retryable() {
193        let err = AgentError::plugin("test", std::io::Error::other("fail"));
194        assert!(!err.is_retryable());
195    }
196
197    #[test]
198    fn content_filtered_not_retryable() {
199        let err = AgentError::ContentFiltered;
200        assert!(!err.is_retryable());
201        assert_eq!(
202            format!("{err}"),
203            "content filtered by provider safety policy"
204        );
205    }
206
207    #[test]
208    fn sync_in_async_context_not_retryable() {
209        let err = AgentError::SyncInAsyncContext;
210        assert!(!err.is_retryable());
211        assert!(format!("{err}").contains("sync API"));
212    }
213}