1use 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 #[error("LLM error ({provider}): {message}")]
16 Llm { provider: String, message: String },
17
18 #[error("LLM rate limited ({provider}): retry after {retry_after_ms}ms")]
20 RateLimited {
21 provider: String,
22 retry_after_ms: u64,
23 },
24
25 #[error("tool error ({name}): {message}")]
31 Tool {
32 name: String,
33 call_id: Option<String>,
35 message: String,
36 },
37
38 #[error("bad tool arguments ({name}): {message}")]
40 BadToolArgs { name: String, message: String },
41
42 #[error("tool rejected ({name}): {reason}")]
44 ToolRejected { name: String, reason: String },
45
46 #[error("tool `{0}` not found")]
48 UnknownTool(String),
49
50 #[error("permission denied: tool {name} ({reason:?})")]
52 PermissionDenied {
53 name: String,
54 reason: DecisionReason,
55 },
56
57 #[error("permission denial limit exceeded for tool {name}")]
59 PermissionDeniedLimit { name: String },
60
61 #[error("llm response truncated by provider (finish_reason = {0:?})")]
63 ProviderTruncated(String),
64
65 #[error("MCP error ({server}): {message}")]
67 Mcp { server: String, message: String },
68
69 #[error("config error: {message}")]
71 Config { message: String },
72
73 #[error("IO error: {0}")]
75 Io(#[from] std::io::Error),
76
77 #[error("HTTP error: {0}")]
79 Http(#[from] reqwest::Error),
80
81 #[error("JSON error: {0}")]
83 Json(#[from] serde_json::Error),
84
85 #[error("cancelled")]
87 Cancelled,
88
89 #[error("timeout after {duration_ms}ms")]
91 Timeout { duration_ms: u64 },
92
93 #[error("storage error: {message}")]
95 Storage { message: String },
96
97 #[error("not found: {0}")]
99 NotFound(String),
100
101 #[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 #[error("internal error ({context}): {message}")]
116 Internal { context: String, message: String },
117}
118
119impl Error {
120 pub fn is_retryable(&self) -> bool {
122 matches!(self, Error::RateLimited { .. } | Error::Timeout { .. })
123 }
124
125 pub fn is_transient(&self) -> bool {
128 matches!(
129 self,
130 Error::RateLimited { .. } | Error::Timeout { .. } | Error::Http(_) | Error::Io(_)
131 )
132 }
133}
134
135pub 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 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 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 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 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 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 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 assert!(!Error::UnknownTool("Bash".into()).is_transient());
422 assert!(!Error::Config {
423 message: "bad config".into()
424 }
425 .is_transient());
426 }
427
428 #[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}