Skip to main content

recursive/
error.rs

1//! Crate-wide error and Result.
2//!
3//! Structured error types that library consumers can match on.
4//! Every distinct failure mode has its own variant.
5
6use crate::permissions::DecisionReason;
7use thiserror::Error;
8
9pub type Result<T, E = Error> = std::result::Result<T, E>;
10
11#[derive(Debug, Error)]
12#[non_exhaustive]
13pub enum Error {
14    /// LLM provider returned an error (HTTP, parse, etc.)
15    #[error("LLM error ({provider}): {message}")]
16    Llm { provider: String, message: String },
17
18    /// LLM rate limited — caller should retry after `retry_after_ms`
19    #[error("LLM rate limited ({provider}): retry after {retry_after_ms}ms")]
20    RateLimited {
21        provider: String,
22        retry_after_ms: u64,
23    },
24
25    /// Tool execution failure (spawn, timeout, I/O)
26    ///
27    /// `call_id` links this error back to the `tool_call_id` of the triggering
28    /// `ToolCall`, enabling audit logs and session persistence to correlate the
29    /// failure with the exact turn that requested the tool.
30    #[error("tool error ({name}): {message}")]
31    Tool {
32        name: String,
33        /// The tool_call_id from the triggering ToolCall, if available.
34        call_id: Option<String>,
35        message: String,
36    },
37
38    /// Invalid arguments passed to a tool
39    #[error("bad tool arguments ({name}): {message}")]
40    BadToolArgs { name: String, message: String },
41
42    /// Tool rejected execution (policy, constraints, safety)
43    #[error("tool rejected ({name}): {reason}")]
44    ToolRejected { name: String, reason: String },
45
46    /// Tool not found in registry
47    #[error("tool `{0}` not found")]
48    UnknownTool(String),
49
50    /// Permission denied for a tool call
51    #[error("permission denied: tool {name} ({reason:?})")]
52    PermissionDenied {
53        name: String,
54        reason: DecisionReason,
55    },
56
57    /// Auto-classifier denial limit exceeded — agent should stop
58    #[error("permission denial limit exceeded for tool {name}")]
59    PermissionDeniedLimit { name: String },
60
61    /// LLM response truncated by provider
62    #[error("llm response truncated by provider (finish_reason = {0:?})")]
63    ProviderTruncated(String),
64
65    /// MCP protocol/transport error
66    #[error("MCP error ({server}): {message}")]
67    Mcp { server: String, message: String },
68
69    /// Configuration error (missing env var, invalid value)
70    #[error("config error: {message}")]
71    Config { message: String },
72
73    /// I/O error
74    #[error("IO error: {0}")]
75    Io(#[from] std::io::Error),
76
77    /// HTTP client error
78    #[error("HTTP error: {0}")]
79    Http(#[from] reqwest::Error),
80
81    /// JSON serialization/deserialization error
82    #[error("JSON error: {0}")]
83    Json(#[from] serde_json::Error),
84
85    /// Operation cancelled via CancellationToken.
86    #[error("cancelled")]
87    Cancelled,
88
89    /// Timeout after a specified duration
90    #[error("timeout after {duration_ms}ms")]
91    Timeout { duration_ms: u64 },
92
93    /// Storage backend error (I/O, serialization, remote storage, etc.)
94    #[error("storage error: {message}")]
95    Storage { message: String },
96
97    /// A named resource (team, task, etc.) was requested but does not exist.
98    #[error("not found: {0}")]
99    NotFound(String),
100
101    /// Session metadata on disk has a `schema_version` newer than this
102    /// build of the binary knows how to interpret. Raised by
103    /// `SessionReader::load_meta` when it sees a version greater than
104    /// the supported maximum — the safe action is to refuse to load
105    /// the session, not to silently drop fields.
106    #[error("session {session_id} has schema_version={found}, supported up to {supported}")]
107    SchemaTooNew {
108        session_id: String,
109        found: u32,
110        supported: u32,
111    },
112
113    /// Internal agent error — unexpected state that does not map to any typed
114    /// variant. Prefer a specific variant; use `Internal` only as last resort.
115    #[error("internal error ({context}): {message}")]
116    Internal { context: String, message: String },
117}
118
119impl Error {
120    /// Returns `true` if the error is safe to retry (rate limits, timeouts).
121    pub fn is_retryable(&self) -> bool {
122        matches!(self, Error::RateLimited { .. } | Error::Timeout { .. })
123    }
124
125    /// Returns `true` if the error is transient (network issues, timeouts).
126    /// Transient errors may resolve without changing the request.
127    pub fn is_transient(&self) -> bool {
128        matches!(
129            self,
130            Error::RateLimited { .. } | Error::Timeout { .. } | Error::Http(_) | Error::Io(_)
131        )
132    }
133}
134
135/// Return `true` when `err` looks like an LLM context-window-exceeded error.
136///
137/// OpenAI-compatible providers (GLM, DeepSeek, …) return HTTP 400 whose body
138/// contains an error code or human-readable message indicating the prompt is
139/// too long. We match several common patterns across providers:
140///
141/// | Provider | Typical signal |
142/// |---|---|
143/// | OpenAI / NIM | `"context_length_exceeded"` (error `code`) |
144/// | OpenAI / NIM | `"maximum context length"` (human message) |
145/// | Some providers | `"context window"` |
146/// | DeepSeek | `"prompt is too long"` |
147/// | Some providers | `"tokens exceeds"` |
148///
149/// The function intentionally casts to lowercase before matching so it is
150/// resilient to capitalisation differences across providers.
151pub fn is_context_window_exceeded(err: &Error) -> bool {
152    if let Error::Llm { message, .. } = err {
153        let msg = message.to_lowercase();
154        msg.contains("context_length_exceeded")
155            || msg.contains("maximum context length")
156            || msg.contains("context window")
157            || msg.contains("prompt is too long")
158            || msg.contains("tokens exceeds")
159            || msg.contains("exceeds the model")
160    } else {
161        false
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn test_llm_error_format() {
171        let err = Error::Llm {
172            provider: "openai".into(),
173            message: "rate limit hit".into(),
174        };
175        let msg = err.to_string();
176        assert!(msg.contains("openai"));
177        assert!(msg.contains("rate limit"));
178    }
179
180    #[test]
181    fn test_rate_limited_format() {
182        let err = Error::RateLimited {
183            provider: "deepseek".into(),
184            retry_after_ms: 5000,
185        };
186        let msg = err.to_string();
187        assert!(msg.contains("deepseek"));
188        assert!(msg.contains("5000"));
189    }
190
191    #[test]
192    fn test_tool_error_format() {
193        let err = Error::Tool {
194            name: "Bash".into(),
195            call_id: None,
196            message: "command not found".into(),
197        };
198        let msg = err.to_string();
199        assert!(msg.contains("Bash"));
200        assert!(msg.contains("command not found"));
201    }
202
203    #[test]
204    fn test_bad_tool_args_format() {
205        let err = Error::BadToolArgs {
206            name: "Read".into(),
207            message: "missing path".into(),
208        };
209        let msg = err.to_string();
210        assert!(msg.contains("Read"));
211        assert!(msg.contains("missing path"));
212    }
213
214    #[test]
215    fn test_permission_denied_format() {
216        let err = Error::PermissionDenied {
217            name: "Bash".into(),
218            reason: crate::permissions::DecisionReason::Mode(
219                crate::permissions::PermissionMode::DontAsk,
220            ),
221        };
222        let msg = err.to_string();
223        assert!(msg.contains("Bash"));
224        assert!(msg.contains("DontAsk"));
225    }
226
227    #[test]
228    fn test_mcp_error_format() {
229        let err = Error::Mcp {
230            server: "filesystem".into(),
231            message: "connection refused".into(),
232        };
233        let msg = err.to_string();
234        assert!(msg.contains("filesystem"));
235        assert!(msg.contains("connection refused"));
236    }
237
238    #[test]
239    fn test_config_error_format() {
240        let err = Error::Config {
241            message: "missing RECURSIVE_API_KEY".into(),
242        };
243        let msg = err.to_string();
244        assert!(msg.contains("missing RECURSIVE_API_KEY"));
245    }
246
247    #[test]
248    fn test_cancelled_format() {
249        let err = Error::Cancelled;
250        let msg = err.to_string();
251        assert!(msg.contains("cancelled"));
252    }
253
254    #[test]
255    fn test_timeout_format() {
256        let err = Error::Timeout { duration_ms: 30000 };
257        let msg = err.to_string();
258        assert!(msg.contains("30000"));
259    }
260
261    #[test]
262    fn test_is_retryable() {
263        assert!(Error::RateLimited {
264            provider: "x".into(),
265            retry_after_ms: 1000
266        }
267        .is_retryable());
268        assert!(Error::Timeout { duration_ms: 5000 }.is_retryable());
269        assert!(!Error::Tool {
270            name: "x".into(),
271            call_id: None,
272            message: "fail".into()
273        }
274        .is_retryable());
275        assert!(!Error::Config {
276            message: "bad".into()
277        }
278        .is_retryable());
279    }
280
281    #[test]
282    fn test_is_transient() {
283        assert!(Error::RateLimited {
284            provider: "x".into(),
285            retry_after_ms: 1000
286        }
287        .is_transient());
288        assert!(Error::Timeout { duration_ms: 5000 }.is_transient());
289        // reqwest::Error is transient by definition (network issues).
290        // We verify the variant match in the is_transient implementation itself.
291        assert!(!Error::Tool {
292            name: "x".into(),
293            call_id: None,
294            message: "fail".into()
295        }
296        .is_transient());
297        assert!(!Error::Config {
298            message: "bad".into()
299        }
300        .is_transient());
301    }
302
303    #[test]
304    fn test_from_io_error() {
305        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
306        let err: Error = io_err.into();
307        assert!(matches!(err, Error::Io(_)));
308        assert!(err.to_string().contains("file not found"));
309    }
310
311    #[test]
312    fn test_unknown_tool_format() {
313        let err = Error::UnknownTool("nonexistent".into());
314        let msg = err.to_string();
315        assert!(msg.contains("nonexistent"));
316    }
317
318    #[test]
319    fn test_provider_truncated_format() {
320        let err = Error::ProviderTruncated("length".into());
321        let msg = err.to_string();
322        assert!(msg.contains("length"));
323    }
324
325    #[test]
326    fn test_tool_rejected_format() {
327        // kills `ToolRejected` match-arm replacement mutations
328        let err = Error::ToolRejected {
329            name: "Bash".into(),
330            reason: "policy violation".into(),
331        };
332        let msg = err.to_string();
333        assert!(
334            msg.contains("Bash"),
335            "name must appear in ToolRejected display"
336        );
337        assert!(
338            msg.contains("policy violation"),
339            "reason must appear in ToolRejected display"
340        );
341    }
342
343    #[test]
344    fn test_permission_denied_limit_format() {
345        // kills `PermissionDeniedLimit` match-arm replacement mutations
346        let err = Error::PermissionDeniedLimit {
347            name: "Write".into(),
348        };
349        let msg = err.to_string();
350        assert!(
351            msg.contains("Write"),
352            "name must appear in PermissionDeniedLimit display"
353        );
354    }
355
356    #[test]
357    fn test_schema_too_new_format() {
358        // kills `SchemaTooNew` match-arm replacement mutations
359        let err = Error::SchemaTooNew {
360            session_id: "sess-123".into(),
361            found: 5,
362            supported: 2,
363        };
364        let msg = err.to_string();
365        assert!(
366            msg.contains("sess-123"),
367            "session_id must appear in SchemaTooNew display"
368        );
369        assert!(msg.contains("5"), "found version must appear");
370        assert!(msg.contains("2"), "supported version must appear");
371    }
372
373    #[test]
374    fn test_internal_error_format() {
375        // kills `Internal` match-arm replacement mutations
376        let err = Error::Internal {
377            context: "run_inner".into(),
378            message: "unexpected None".into(),
379        };
380        let msg = err.to_string();
381        assert!(
382            msg.contains("run_inner"),
383            "context must appear in Internal display"
384        );
385        assert!(
386            msg.contains("unexpected None"),
387            "message must appear in Internal display"
388        );
389    }
390
391    #[test]
392    fn test_storage_error_format() {
393        let err = Error::Storage {
394            message: "disk full".into(),
395        };
396        let msg = err.to_string();
397        assert!(msg.contains("disk full"));
398    }
399
400    #[test]
401    fn test_not_found_format() {
402        let err = Error::NotFound("task-abc".into());
403        let msg = err.to_string();
404        assert!(msg.contains("task-abc"));
405    }
406
407    #[test]
408    fn test_is_retryable_for_non_retryable_variants() {
409        // kills mutations that make non-retryable errors retryable
410        assert!(!Error::UnknownTool("Bash".into()).is_retryable());
411        assert!(!Error::BadToolArgs {
412            name: "r".into(),
413            message: "m".into()
414        }
415        .is_retryable());
416    }
417
418    #[test]
419    fn test_is_transient_for_non_transient_variants() {
420        // kills mutations that expand the is_transient match arms
421        assert!(!Error::UnknownTool("Bash".into()).is_transient());
422        assert!(!Error::Config {
423            message: "bad config".into()
424        }
425        .is_transient());
426    }
427
428    // ── is_context_window_exceeded ────────────────────────────────────
429
430    #[test]
431    fn context_overflow_matches_known_patterns() {
432        let cases = [
433            "HTTP 400: {\"error\":{\"code\":\"context_length_exceeded\",\"message\":\"too long\"}}",
434            "HTTP 400: This model's maximum context length is 200000 tokens",
435            "HTTP 400: prompt is too long for the model",
436            "HTTP 400: tokens exceeds model limit",
437            "HTTP 400: exceeds the model context window",
438        ];
439        for msg in &cases {
440            let err = Error::Llm {
441                provider: "test".into(),
442                message: msg.to_string(),
443            };
444            assert!(
445                is_context_window_exceeded(&err),
446                "should detect context overflow in: {msg}"
447            );
448        }
449    }
450
451    #[test]
452    fn context_overflow_ignores_unrelated_errors() {
453        let cases = [
454            "HTTP 400: invalid request body",
455            "HTTP 401: unauthorized",
456            "HTTP 429: rate limit exceeded",
457            "network error: connection refused",
458        ];
459        for msg in &cases {
460            let err = Error::Llm {
461                provider: "test".into(),
462                message: msg.to_string(),
463            };
464            assert!(
465                !is_context_window_exceeded(&err),
466                "should NOT detect context overflow in: {msg}"
467            );
468        }
469    }
470
471    #[test]
472    fn context_overflow_ignores_non_llm_errors() {
473        assert!(!is_context_window_exceeded(&Error::Timeout {
474            duration_ms: 30_000
475        }));
476        let err = Error::Config {
477            message: "context_length_exceeded".into(),
478        };
479        assert!(
480            !is_context_window_exceeded(&err),
481            "Config errors must not be detected even if they contain the keyword"
482        );
483    }
484}