Skip to main content

agentyk_core/
error.rs

1//! Crate error type.
2
3use std::fmt;
4
5/// Coarse classification of an LLM driver failure, so a host (in particular
6/// a durable executor retrying activities) can tell a transient failure
7/// worth retrying apart from a terminal one that will never succeed by
8/// itself. Mirrors the intent of everruns' `LlmErrorKind`.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10#[non_exhaustive]
11pub enum LlmErrorKind {
12    /// The provider rejected the request for being over its rate limit
13    /// (HTTP 429). Retry after a backoff.
14    RateLimited,
15    /// The provider is temporarily overloaded (HTTP 503, Anthropic 529).
16    /// Retry after a backoff.
17    Overloaded,
18    /// The request timed out before a response arrived. Retry.
19    Timeout,
20    /// A transport-level failure (connection refused, DNS, TLS, a stream
21    /// read that dropped mid-response). Retry.
22    Network,
23    /// Generic server-side failure (HTTP 5xx not otherwise classified).
24    /// Usually worth one retry.
25    ServerError,
26    /// Missing or rejected credentials (HTTP 401/403, or no api key was
27    /// configured at all). Will never succeed without a config change —
28    /// do not retry.
29    Authentication,
30    /// The request itself was malformed (HTTP 400/404/422 or similar).
31    /// Will never succeed unchanged — do not retry.
32    InvalidRequest,
33    /// Unclassified — an unexpected response shape, a decode failure, or a
34    /// status code with no clear category. Conservative default: do not
35    /// retry blindly.
36    Unknown,
37}
38
39impl LlmErrorKind {
40    /// Whether a host should consider retrying the request after a backoff.
41    pub fn is_retryable(&self) -> bool {
42        matches!(
43            self,
44            LlmErrorKind::RateLimited
45                | LlmErrorKind::Overloaded
46                | LlmErrorKind::Timeout
47                | LlmErrorKind::Network
48                | LlmErrorKind::ServerError
49        )
50    }
51}
52
53impl fmt::Display for LlmErrorKind {
54    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55        let label = match self {
56            LlmErrorKind::RateLimited => "rate_limited",
57            LlmErrorKind::Overloaded => "overloaded",
58            LlmErrorKind::Timeout => "timeout",
59            LlmErrorKind::Network => "network",
60            LlmErrorKind::ServerError => "server_error",
61            LlmErrorKind::Authentication => "authentication",
62            LlmErrorKind::InvalidRequest => "invalid_request",
63            LlmErrorKind::Unknown => "unknown",
64        };
65        f.write_str(label)
66    }
67}
68
69/// Errors surfaced by agentyk.
70#[derive(Debug, thiserror::Error)]
71#[non_exhaustive]
72pub enum Error {
73    /// The agent was composed without a model, or is otherwise invalid.
74    #[error("invalid agent: {0}")]
75    InvalidAgent(String),
76
77    /// No driver registered for the model's `DriverId`.
78    #[error("no chat driver registered for `{0}`")]
79    UnknownDriver(String),
80
81    /// A turn hit the iteration ceiling without producing a final response.
82    #[error("turn exceeded max iterations ({0})")]
83    MaxIterations(usize),
84
85    /// The turn was cancelled via a [`crate::cancellation::CancellationToken`].
86    #[error("turn cancelled")]
87    Cancelled,
88
89    /// The turn was deliberately sealed — see [`crate::turn::SealReason`].
90    #[error("turn sealed: {0:?}")]
91    Sealed(crate::turn::SealReason),
92
93    /// A blockable user hook refused the prompt or tool action.
94    #[error("hook blocked execution: {reason}")]
95    HookBlocked {
96        /// Diagnostic reason supplied by the hook.
97        reason: String,
98        /// Optional message suitable for a user-facing surface.
99        user_message: Option<String>,
100    },
101
102    /// An LLM driver failed. `kind` classifies whether retrying is worth it
103    /// — see [`Error::is_retryable`].
104    #[error("driver error ({kind}): {message}")]
105    Driver {
106        /// Whether retrying is worth it.
107        kind: LlmErrorKind,
108        /// What the provider or transport reported.
109        message: String,
110    },
111
112    /// An MCP server or transport failed.
113    #[error("mcp error: {0}")]
114    Mcp(String),
115
116    /// The event log failed to append or read.
117    #[error("event log error: {0}")]
118    EventLog(String),
119
120    /// An append expected a different end-of-stream sequence.
121    #[error("event stream conflict: expected version {expected}, found {actual}")]
122    EventConflict {
123        /// Version supplied by the writer.
124        expected: u64,
125        /// Version currently stored.
126        actual: u64,
127    },
128
129    /// Local I/O failed — an event log file, a workspace read.
130    #[error(transparent)]
131    Io(#[from] std::io::Error),
132
133    /// Serialization or deserialization failed.
134    #[error(transparent)]
135    Json(#[from] serde_json::Error),
136
137    /// Anything without a more specific variant.
138    #[error("{0}")]
139    Other(String),
140}
141
142impl Error {
143    /// Shorthand for `Error::Driver { kind, message: message.into() }`.
144    pub fn driver(kind: LlmErrorKind, message: impl Into<String>) -> Self {
145        Error::Driver {
146            kind,
147            message: message.into(),
148        }
149    }
150
151    /// Whether a host should consider retrying the operation that produced
152    /// this error. Only [`Error::Driver`] carries enough information to say
153    /// yes; every other variant is a local/config problem retrying can't fix.
154    pub fn is_retryable(&self) -> bool {
155        match self {
156            Error::Driver { kind, .. } => kind.is_retryable(),
157            _ => false,
158        }
159    }
160}
161
162/// `Result` with this crate's [`Error`].
163pub type Result<T> = std::result::Result<T, Error>;
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn retryable_kinds_match_the_documented_set() {
171        assert!(LlmErrorKind::RateLimited.is_retryable());
172        assert!(LlmErrorKind::Overloaded.is_retryable());
173        assert!(LlmErrorKind::Timeout.is_retryable());
174        assert!(LlmErrorKind::Network.is_retryable());
175        assert!(LlmErrorKind::ServerError.is_retryable());
176        assert!(!LlmErrorKind::Authentication.is_retryable());
177        assert!(!LlmErrorKind::InvalidRequest.is_retryable());
178        assert!(!LlmErrorKind::Unknown.is_retryable());
179    }
180
181    #[test]
182    fn error_is_retryable_delegates_to_driver_kind_only() {
183        assert!(Error::driver(LlmErrorKind::RateLimited, "429").is_retryable());
184        assert!(!Error::driver(LlmErrorKind::Authentication, "401").is_retryable());
185        assert!(!Error::Cancelled.is_retryable());
186        assert!(!Error::MaxIterations(4).is_retryable());
187    }
188}