Skip to main content

telos_agent/
error.rs

1//! Unified error type for the crate.
2//!
3//! All public APIs surface failures through [`AgentError`]. Each variant
4//! identifies a distinct failure class so callers can pattern-match instead of
5//! parsing error strings.
6
7use serde::Serialize;
8use thiserror::Error;
9
10/// Structured failure categories for provider (HTTP / API / stream) errors.
11///
12/// Carrying the HTTP status code lets the runtime decide whether an error is
13/// transient and worth retrying without parsing free-text messages.
14#[derive(Debug, Error, Clone, Serialize)]
15pub enum ProviderError {
16    /// An HTTP response with a non-success status code.
17    #[error("HTTP {status}: {message}")]
18    Http { status: u16, message: String },
19    /// A low-level network error (connection reset, refused, EOF, etc.).
20    #[error("network error: {0}")]
21    Network(String),
22    /// A provider API-level error response.
23    #[error("API error: {0}")]
24    Api(String),
25    /// The request timed out before completing.
26    #[error("timeout")]
27    Timeout,
28    /// The provider stream ended before a complete message was received.
29    #[error("stream ended unexpectedly")]
30    StreamEnded,
31    /// The provider returned a response that could not be parsed.
32    #[error("invalid response: {0}")]
33    InvalidResponse(String),
34    /// Any other provider failure that does not fit the categories above.
35    #[error("{0}")]
36    Other(String),
37}
38
39/// All error conditions surfaced by the agent runtime.
40#[derive(Debug, Error, Clone, Serialize)]
41pub enum AgentError {
42    /// The model provider (HTTP transport, API contract, deserialisation, …) failed.
43    #[error("provider error: {0}")]
44    Provider(ProviderError),
45    /// A misconfigured session or backend (missing env var, unwritable storage dir, …).
46    #[error("configuration error: {0}")]
47    Config(String),
48    /// Tool input failed schema/business-rule validation before execution.
49    #[error("validation error: {0}")]
50    Validation(String),
51    /// The permission engine or a tool refused to execute the call.
52    #[error("permission denied: {0}")]
53    PermissionDenied(String),
54    /// The assistant requested a tool that isn't in the registry.
55    #[error("tool not found: {0}")]
56    ToolNotFound(String),
57    /// A tool ran but reported a runtime failure.
58    #[error("tool `{tool}` failed: {message}")]
59    ToolExecution { tool: String, message: String },
60    /// The turn loop exceeded [`AgentConfig::max_iterations`](crate::AgentConfig::max_iterations).
61    #[error("maximum tool iterations reached: {0}")]
62    MaxIterations(usize),
63    /// Provider retries exhausted — every attempt failed.
64    #[error("provider retries exhausted after {attempts} attempts: {last_error}")]
65    ProviderRetriesExhausted { attempts: usize, last_error: String },
66    /// Another turn is already running for this session.
67    #[error("session is already running a turn")]
68    SessionBusy,
69    /// The turn was cancelled via [`CancellationState`](crate::CancellationState).
70    #[error("cancelled")]
71    Cancelled,
72}
73
74impl AgentError {
75    /// Whether the error is transient and worth retrying.
76    ///
77    /// Network errors and server-side 429/5xx responses are retryable;
78    /// configuration errors, validation failures, and permission denials are not.
79    pub fn is_retryable(&self) -> bool {
80        match self {
81            AgentError::Provider(err) => match err {
82                ProviderError::Http { status, .. } => {
83                    *status == 429 || (500..=599).contains(status)
84                }
85                ProviderError::Network(_) | ProviderError::Timeout => true,
86                _ => false,
87            },
88            _ => false,
89        }
90    }
91
92    /// Whether the error indicates the context window was exceeded.
93    ///
94    /// HTTP 400 with a message about context/token length means the request
95    /// was too large for the model. This is NOT retryable in the normal sense
96    /// (repeating the same request will fail), but it IS recoverable via
97    /// compaction: summarise history, then retry with fewer tokens.
98    pub fn is_context_too_long(&self) -> bool {
99        match self {
100            AgentError::Provider(ProviderError::Http { status, message }) if *status == 400 => {
101                let lower = message.to_lowercase();
102                lower.contains("context")
103                    || lower.contains("token")
104                    || lower.contains("too long")
105                    || lower.contains("too large")
106                    || lower.contains("exceeds")
107                    || lower.contains("maximum")
108                    || lower.contains("length")
109            }
110            AgentError::Provider(ProviderError::Api(message)) => {
111                let lower = message.to_lowercase();
112                lower.contains("context") || lower.contains("token") || lower.contains("too long")
113            }
114            _ => false,
115        }
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn http_429_is_retryable() {
125        let err = AgentError::Provider(ProviderError::Http {
126            status: 429,
127            message: "rate limited".into(),
128        });
129        assert!(err.is_retryable());
130    }
131
132    #[test]
133    fn http_5xx_is_retryable() {
134        for status in [500, 502, 503, 504] {
135            let err = AgentError::Provider(ProviderError::Http {
136                status,
137                message: "server error".into(),
138            });
139            assert!(err.is_retryable(), "status {status} should be retryable");
140        }
141    }
142
143    #[test]
144    fn http_4xx_other_than_429_is_not_retryable() {
145        for status in [400, 401, 403, 404, 422] {
146            let err = AgentError::Provider(ProviderError::Http {
147                status,
148                message: "client error".into(),
149            });
150            assert!(!err.is_retryable(), "status {status} should not be retryable");
151        }
152    }
153
154    #[test]
155    fn network_and_timeout_errors_are_retryable() {
156        assert!(AgentError::Provider(ProviderError::Network("reset".into())).is_retryable());
157        assert!(AgentError::Provider(ProviderError::Timeout).is_retryable());
158    }
159
160    #[test]
161    fn api_invalid_response_stream_ended_are_not_retryable() {
162        assert!(!AgentError::Provider(ProviderError::Api("invalid key".into())).is_retryable());
163        assert!(
164            !AgentError::Provider(ProviderError::InvalidResponse("bad json".into())).is_retryable()
165        );
166        assert!(!AgentError::Provider(ProviderError::StreamEnded).is_retryable());
167    }
168
169    #[test]
170    fn non_provider_errors_are_not_retryable() {
171        assert!(!AgentError::Config("bad".into()).is_retryable());
172        assert!(!AgentError::Validation("bad".into()).is_retryable());
173        assert!(!AgentError::PermissionDenied("no".into()).is_retryable());
174        assert!(!AgentError::ToolNotFound("x".into()).is_retryable());
175    }
176}