1use thiserror::Error;
12
13#[derive(Debug, Error)]
24#[non_exhaustive]
25pub enum Error {
26 #[error("LLM error: {0}")]
28 Llm(#[from] LlmError),
29 #[error("Tool error: {0}")]
31 Tool(#[from] ToolError),
32 #[error("Memory error: {0}")]
34 Memory(#[from] MemoryError),
35 #[error("Bus error: {0}")]
37 Bus(#[from] BusError),
38 #[error("max steps exceeded ({steps})")]
40 MaxStepsExceeded {
41 steps: u32,
43 },
44 #[error("cancelled")]
46 Cancelled,
47 #[error("config error: {0}")]
49 Config(#[from] ConfigError),
50 #[error("bad response: {0}")]
56 BadResponse(String),
57 #[error("guardrail refused: {reason}")]
59 Refused {
60 reason: String,
62 },
63 #[error("guardrail handoff to {agent}: {reason}")]
65 Handoff {
66 agent: String,
68 reason: String,
70 },
71 #[error("App build error: missing {missing}")]
76 AppBuildError {
77 missing: &'static str,
79 },
80 #[error("model {model} sunset (since {since})")]
82 ModelSunset {
83 model: String,
85 since: String,
87 },
88 #[error("downstream error: {0}")]
98 Downstream(#[source] Box<dyn std::error::Error + Send + Sync>),
99 #[error("run suspended: {reason}")]
102 Suspended {
103 checkpoint: Box<crate::checkpoint::RunCheckpoint>,
105 reason: String,
107 },
108 #[error("resume blocked for run {run_id}: non-idempotent tool(s) {tools:?} already attempted; manual reconciliation required")]
114 ResumeReplayBlocked {
115 run_id: crate::ids::RunId,
117 tools: Vec<String>,
119 },
120 #[error("{message}")]
130 Other {
131 message: String,
133 #[source]
135 source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
136 },
137}
138
139impl Error {
140 pub fn wrap<E>(message: impl Into<String>, source: E) -> Self
151 where
152 E: std::error::Error + Send + Sync + 'static,
153 {
154 Self::Other {
155 message: message.into(),
156 source: Some(Box::new(source)),
157 }
158 }
159
160 pub fn retryable(&self) -> bool {
162 match self {
163 Self::Llm(e) => e.retryable(),
164 Self::Tool(e) => e.retryable(),
165 Self::Bus(e) => e.retryable(),
166 Self::Memory(_)
167 | Self::MaxStepsExceeded { .. }
168 | Self::Cancelled
169 | Self::Config(_)
170 | Self::BadResponse(_)
171 | Self::Refused { .. }
172 | Self::Handoff { .. }
173 | Self::AppBuildError { .. }
174 | Self::ModelSunset { .. }
175 | Self::Suspended { .. }
176 | Self::ResumeReplayBlocked { .. }
177 | Self::Downstream(_)
178 | Self::Other { .. } => false,
179 }
180 }
181}
182
183#[cfg(test)]
184mod suspended_variant_tests {
185 use super::*;
186
187 #[test]
188 fn suspended_error_displays_reason() {
189 use crate::checkpoint::RunCheckpoint;
190 let cp = RunCheckpoint::for_test();
191 let e = Error::Suspended {
192 checkpoint: Box::new(cp),
193 reason: "needs approval".into(),
194 };
195 assert!(e.to_string().contains("needs approval"));
196 assert!(
197 !e.retryable(),
198 "a suspended run resumes on an operator decision, never by blind retry"
199 );
200 }
201
202 #[test]
203 fn resume_replay_blocked_is_not_retryable_and_names_run_and_tools() {
204 let run_id = crate::ids::RunId::new();
205 let e = Error::ResumeReplayBlocked {
206 run_id,
207 tools: vec!["authorize_payout".into()],
208 };
209 assert!(
210 !e.retryable(),
211 "a blocked resume needs operator reconciliation, never a blind retry"
212 );
213 let msg = e.to_string();
214 assert!(msg.contains(&run_id.to_string()) && msg.contains("authorize_payout"));
215 }
216
217 #[test]
218 fn llm_suspended_displays_reason_and_is_not_retryable() {
219 let e = LlmError::Suspended {
220 reason: "approve the payout".into(),
221 };
222 assert!(e.to_string().contains("approve the payout"));
223 assert!(
224 !e.retryable(),
225 "the streaming suspend terminal item resumes on a decision, never by retry"
226 );
227 }
228}
229
230#[cfg(test)]
231mod other_variant_tests {
232 use super::*;
233 use std::error::Error as StdError;
234
235 #[test]
236 fn other_message_surfaces_through_display() {
237 let e = Error::Other {
238 message: "quality loop: short draft".into(),
239 source: None,
240 };
241 assert_eq!(e.to_string(), "quality loop: short draft");
242 }
243
244 #[test]
245 fn other_preserves_source_chain() {
246 let inner = std::io::Error::other("disk gone");
247 let e = Error::Other {
248 message: "ledger write failed".into(),
249 source: Some(Box::new(inner)),
250 };
251 let src = e.source().expect("source must be preserved");
252 assert_eq!(src.to_string(), "disk gone");
253 }
254
255 #[test]
256 fn other_without_source_returns_none() {
257 let e = Error::Other {
258 message: "no inner".into(),
259 source: None,
260 };
261 assert!(e.source().is_none());
262 }
263
264 #[test]
265 fn wrap_ctor_boxes_source_and_carries_message() {
266 let inner = std::io::Error::other("disk gone");
267 let e = Error::wrap("ledger write failed", inner);
268 assert_eq!(e.to_string(), "ledger write failed");
269 let src = e.source().expect("source must be preserved by wrap()");
270 assert_eq!(src.to_string(), "disk gone");
271 }
272
273 #[test]
274 fn other_is_not_retryable() {
275 let with_source = Error::Other {
276 message: "x".into(),
277 source: Some(Box::new(std::io::Error::other("y"))),
278 };
279 let without_source = Error::Other {
280 message: "x".into(),
281 source: None,
282 };
283 assert!(!with_source.retryable());
284 assert!(!without_source.retryable());
285 }
286
287 #[test]
288 fn downstream_preserves_source_chain() {
289 let inner = std::io::Error::other("db gone");
290 let e = Error::Downstream(Box::new(inner));
291 assert_eq!(e.to_string(), "downstream error: db gone");
292 let src = e.source().expect("source must be preserved");
293 assert_eq!(src.to_string(), "db gone");
294 }
295
296 #[test]
297 fn downstream_is_not_retryable() {
298 let e = Error::Downstream(Box::new(std::io::Error::other("transient")));
299 assert!(!e.retryable());
300 }
301}
302
303#[derive(Debug, Error)]
308#[non_exhaustive]
309pub enum LlmError {
310 #[error("network error: {0}")]
312 Network(String),
313 #[error("timeout")]
315 Timeout,
316 #[error("rate limited (retry after {retry_after_secs}s)")]
318 RateLimit {
319 retry_after_secs: u32,
321 },
322 #[error("unauthorized")]
324 Unauthorized,
325 #[error("bad request: {0}")]
327 BadRequest(String),
328 #[error("server error: {0}")]
330 Server(String),
331 #[error("decoding error: {0}")]
333 Decoding(String),
334 #[error("unsupported capability: {0}")]
336 Unsupported(String),
337 #[error("operation cancelled")]
341 Cancelled,
342 #[error("suspended for human review: {reason}")]
349 Suspended {
350 reason: String,
353 },
354}
355
356impl LlmError {
357 pub fn retryable(&self) -> bool {
359 matches!(
360 self,
361 Self::Network(_) | Self::Timeout | Self::RateLimit { .. } | Self::Server(_)
362 )
363 }
364}
365
366#[derive(Debug, Error)]
368#[non_exhaustive]
369pub enum ToolError {
370 #[error("unknown tool: {0}")]
372 UnknownTool(String),
373 #[error("invalid args: {0}")]
375 InvalidArgs(String),
376 #[error("retryable: {message} (retry after {retry_after_secs}s)")]
378 Retryable {
379 message: String,
381 retry_after_secs: u32,
383 },
384 #[error("permanent: {0}")]
386 Permanent(String),
387 #[error("timeout")]
389 Timeout,
390 #[error("cancelled")]
393 Cancelled,
394}
395
396impl ToolError {
397 pub fn retryable(&self) -> bool {
399 matches!(self, Self::Retryable { .. } | Self::Timeout)
400 }
401}
402
403#[derive(Debug, Error)]
405#[non_exhaustive]
406pub enum MemoryError {
407 #[error("store error: {0}")]
409 Store(String),
410 #[error("not found")]
412 NotFound,
413 #[error("embedding failed: {0}")]
415 Embedding(String),
416 #[error("serialization: {0}")]
418 Serialization(String),
419}
420
421#[derive(Debug, Error)]
423#[non_exhaustive]
424pub enum BusError {
425 #[error("connection error: {0}")]
427 Connection(String),
428 #[error("not found: {0}")]
430 NotFound(String),
431 #[error("cas conflict (expected {expected}, got {actual})")]
433 CasConflict {
434 expected: u64,
436 actual: u64,
438 },
439 #[error("timeout")]
441 Timeout,
442 #[error("retryable: {0}")]
444 Retryable(String),
445 #[error("permanent: {0}")]
447 Permanent(String),
448 #[error("unsupported operation: {0}")]
450 Unsupported(String),
451 #[error("invalid argument: {0}")]
455 Invalid(String),
456}
457
458impl BusError {
459 pub fn retryable(&self) -> bool {
461 matches!(
462 self,
463 Self::Connection(_) | Self::Timeout | Self::Retryable(_)
464 )
465 }
466}
467
468#[derive(Debug, Error)]
470#[non_exhaustive]
471pub enum ConfigError {
472 #[error("missing required key: {0}")]
474 MissingKey(String),
475 #[error("invalid value for {key}: {reason}")]
477 InvalidValue {
478 key: String,
480 reason: String,
482 },
483}
484
485#[cfg(test)]
486mod tests {
487 use super::*;
488
489 #[test]
490 fn llm_timeout_is_retryable() {
491 let e: Error = LlmError::Timeout.into();
492 assert!(e.retryable());
493 }
494
495 #[test]
496 fn llm_unauthorized_is_not_retryable() {
497 let e: Error = LlmError::Unauthorized.into();
498 assert!(!e.retryable());
499 }
500
501 #[test]
502 fn tool_invalid_args_is_not_retryable() {
503 let e: Error = ToolError::InvalidArgs("bad json".into()).into();
504 assert!(!e.retryable());
505 }
506
507 #[test]
508 fn config_error_is_not_retryable() {
509 let e: Error = ConfigError::MissingKey("x".into()).into();
510 assert!(!e.retryable());
511 }
512
513 #[test]
514 fn refused_is_not_retryable() {
515 let e = Error::Refused {
516 reason: "policy".into(),
517 };
518 assert!(!e.retryable());
519 }
520
521 #[test]
522 fn handoff_is_not_retryable() {
523 let e = Error::Handoff {
524 agent: "specialist".into(),
525 reason: "out of scope".into(),
526 };
527 assert!(!e.retryable());
528 }
529
530 #[test]
531 fn cancelled_variant_renders_stable_message() {
532 let e = ToolError::Cancelled;
533 assert_eq!(e.to_string(), "cancelled");
534 assert!(!e.retryable());
535 }
536}