Skip to main content

llm_agent_runtime/
error.rs

1//! # Unified error type for the agent-runtime crate.
2//!
3//! ## Responsibility
4//! Provide a single, typed error enum that covers all subsystems:
5//! memory, graph, orchestration, and the ReAct agent loop.
6//!
7//! ## Guarantees
8//! - Every variant is named and carries structured context
9//! - Implements `std::error::Error` via `thiserror`
10//! - Safe to send across thread/task boundaries (`Send + Sync`)
11//! - Never panics
12
13/// Unified error type returned by all public `agent-runtime` APIs.
14///
15/// Marked `#[non_exhaustive]` so that adding new variants in future minor
16/// releases does not break external `match` arms.
17#[non_exhaustive]
18#[derive(Debug, thiserror::Error)]
19pub enum AgentRuntimeError {
20    /// A memory subsystem operation failed (episodic, semantic, or working memory).
21    #[error("Memory operation failed: {0}")]
22    Memory(String),
23
24    /// A graph subsystem operation failed (entity/relationship management or traversal).
25    #[error("Graph operation failed: {0}")]
26    Graph(String),
27
28    /// The orchestration pipeline or one of its stages failed.
29    #[error("Orchestration failed: {0}")]
30    Orchestration(String),
31
32    /// The ReAct agent loop encountered an unrecoverable error.
33    #[error("Agent loop error: {0}")]
34    AgentLoop(String),
35
36    /// The runtime was used before a required subsystem was configured.
37    #[error("Runtime not configured: missing '{0}'")]
38    NotConfigured(&'static str),
39
40    /// Circuit breaker is open — fast-fail without attempting the operation.
41    #[error("Circuit breaker open for '{service}'")]
42    CircuitOpen {
43        /// Name of the service whose circuit breaker is open.
44        service: String,
45    },
46
47    /// Backpressure threshold exceeded — caller must shed or wait.
48    #[error("Backpressure threshold exceeded: queue depth {depth}/{capacity}")]
49    BackpressureShed {
50        /// Current in-flight request count at the time of rejection.
51        depth: usize,
52        /// Maximum allowed in-flight request count.
53        capacity: usize,
54    },
55
56    /// A deduplication key collision was detected.
57    #[error("Deduplication key collision: {key}")]
58    DeduplicationConflict {
59        /// The duplicated request key.
60        key: String,
61    },
62
63    /// An LLM provider call failed.
64    #[error("Provider error: {0}")]
65    Provider(String),
66
67    /// A persistence operation failed.
68    #[error("Persistence error: {0}")]
69    Persistence(String),
70
71    /// A tool argument validation failed.
72    #[error("Validation failed for field '{field}': [{code}] {message}")]
73    Validation {
74        /// The argument field that failed validation.
75        field: String,
76        /// A short machine-readable code (e.g. "out_of_range", "pattern_mismatch").
77        code: String,
78        /// Human-readable description of the failure.
79        message: String,
80    },
81}
82
83impl AgentRuntimeError {
84    /// Return `true` if this is a `CircuitOpen` error.
85    pub fn is_circuit_open(&self) -> bool {
86        matches!(self, Self::CircuitOpen { .. })
87    }
88
89    /// Return `true` if this is a `BackpressureShed` error.
90    pub fn is_backpressure(&self) -> bool {
91        matches!(self, Self::BackpressureShed { .. })
92    }
93
94    /// Return `true` if this is a `Provider` error.
95    pub fn is_provider(&self) -> bool {
96        matches!(self, Self::Provider(_))
97    }
98
99    /// Return `true` if this is a `Validation` error.
100    pub fn is_validation(&self) -> bool {
101        matches!(self, Self::Validation { .. })
102    }
103
104    /// Return `true` if this is a `Memory` error.
105    pub fn is_memory(&self) -> bool {
106        matches!(self, Self::Memory(_))
107    }
108
109    /// Return `true` if this is a `Graph` error.
110    pub fn is_graph(&self) -> bool {
111        matches!(self, Self::Graph(_))
112    }
113
114    /// Return `true` if this is an `AgentLoop` error.
115    pub fn is_agent_loop(&self) -> bool {
116        matches!(self, Self::AgentLoop(_))
117    }
118
119    /// Return `true` if this is an `Orchestration` error.
120    pub fn is_orchestration(&self) -> bool {
121        matches!(self, Self::Orchestration(_))
122    }
123
124    /// Return `true` if this is a `Persistence` error.
125    pub fn is_persistence(&self) -> bool {
126        matches!(self, Self::Persistence(_))
127    }
128
129    /// Return `true` if this is a `NotConfigured` error.
130    pub fn is_not_configured(&self) -> bool {
131        matches!(self, Self::NotConfigured(_))
132    }
133
134    /// Return `true` if this is a `DeduplicationConflict` error.
135    pub fn is_deduplication_conflict(&self) -> bool {
136        matches!(self, Self::DeduplicationConflict { .. })
137    }
138
139    /// Return `true` if this error is likely transient and safe to retry.
140    ///
141    /// `Provider` and `Persistence` errors (e.g. network timeouts, I/O failures)
142    /// are classified as retryable.  Logic errors (`Memory`, `Graph`,
143    /// `Orchestration`, `AgentLoop`, `NotConfigured`, `Validation`,
144    /// `CircuitOpen`, `BackpressureShed`, `DeduplicationConflict`) are not.
145    pub fn is_retryable(&self) -> bool {
146        matches!(self, Self::Provider(_) | Self::Persistence(_))
147    }
148
149    /// Extract the primary message string from this error.
150    ///
151    /// For simple string-carrying variants (`Memory`, `Graph`, `Orchestration`,
152    /// `AgentLoop`, `Provider`, `Persistence`) this returns the inner `String`.
153    /// For structured variants the `Display` representation is returned so
154    /// callers always get a non-empty, human-readable string.
155    pub fn message(&self) -> String {
156        match self {
157            Self::Memory(s)
158            | Self::Graph(s)
159            | Self::Orchestration(s)
160            | Self::AgentLoop(s)
161            | Self::Provider(s)
162            | Self::Persistence(s) => s.clone(),
163            Self::NotConfigured(s) => s.to_string(),
164            Self::CircuitOpen { service } => format!("circuit open for '{service}'"),
165            Self::BackpressureShed { depth, capacity } => {
166                format!("backpressure: queue depth {depth}/{capacity}")
167            }
168            Self::DeduplicationConflict { key } => format!("dedup conflict: {key}"),
169            Self::Validation { field, code, message } => {
170                format!("[{code}] {field}: {message}")
171            }
172        }
173    }
174}
175
176impl From<serde_json::Error> for AgentRuntimeError {
177    fn from(e: serde_json::Error) -> Self {
178        AgentRuntimeError::AgentLoop(format!("JSON error: {e}"))
179    }
180}
181
182impl From<std::io::Error> for AgentRuntimeError {
183    fn from(e: std::io::Error) -> Self {
184        AgentRuntimeError::Persistence(format!("I/O error: {e}"))
185    }
186}
187
188impl From<Box<dyn std::error::Error + Send + Sync>> for AgentRuntimeError {
189    /// Convert any boxed `Send + Sync` error into an `AgentRuntimeError::AgentLoop`.
190    ///
191    /// This is a catch-all conversion that lets library users propagate arbitrary
192    /// errors through `?` in tool handlers and inference closures without having to
193    /// manually map each error type.  The error message is preserved verbatim.
194    fn from(e: Box<dyn std::error::Error + Send + Sync>) -> Self {
195        AgentRuntimeError::AgentLoop(e.to_string())
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn test_memory_error_display() {
205        let e = AgentRuntimeError::Memory("store full".into());
206        assert_eq!(e.to_string(), "Memory operation failed: store full");
207    }
208
209    #[test]
210    fn test_graph_error_display() {
211        let e = AgentRuntimeError::Graph("entity not found".into());
212        assert_eq!(e.to_string(), "Graph operation failed: entity not found");
213    }
214
215    #[test]
216    fn test_orchestration_error_display() {
217        let e = AgentRuntimeError::Orchestration("pipeline stalled".into());
218        assert_eq!(e.to_string(), "Orchestration failed: pipeline stalled");
219    }
220
221    #[test]
222    fn test_agent_loop_error_display() {
223        let e = AgentRuntimeError::AgentLoop("max iterations".into());
224        assert_eq!(e.to_string(), "Agent loop error: max iterations");
225    }
226
227    #[test]
228    fn test_not_configured_error_display() {
229        let e = AgentRuntimeError::NotConfigured("memory");
230        assert_eq!(e.to_string(), "Runtime not configured: missing 'memory'");
231    }
232
233    #[test]
234    fn test_circuit_open_error_display() {
235        let e = AgentRuntimeError::CircuitOpen {
236            service: "llm-api".into(),
237        };
238        assert_eq!(e.to_string(), "Circuit breaker open for 'llm-api'");
239    }
240
241    #[test]
242    fn test_backpressure_shed_error_display() {
243        let e = AgentRuntimeError::BackpressureShed {
244            depth: 100,
245            capacity: 100,
246        };
247        assert_eq!(
248            e.to_string(),
249            "Backpressure threshold exceeded: queue depth 100/100"
250        );
251    }
252
253    #[test]
254    fn test_deduplication_conflict_display() {
255        let e = AgentRuntimeError::DeduplicationConflict {
256            key: "abc123".into(),
257        };
258        assert_eq!(e.to_string(), "Deduplication key collision: abc123");
259    }
260
261    #[test]
262    fn test_error_is_send_sync() {
263        fn assert_send_sync<T: Send + Sync>() {}
264        assert_send_sync::<AgentRuntimeError>();
265    }
266
267    #[test]
268    fn test_error_debug_format() {
269        let e = AgentRuntimeError::Memory("test".into());
270        let debug = format!("{:?}", e);
271        assert!(debug.contains("Memory"));
272    }
273
274    #[test]
275    fn test_validation_error_display() {
276        let e = AgentRuntimeError::Validation {
277            field: "n".into(),
278            code: "out_of_range".into(),
279            message: "n must be between 1 and 100".into(),
280        };
281        assert_eq!(
282            e.to_string(),
283            "Validation failed for field 'n': [out_of_range] n must be between 1 and 100"
284        );
285    }
286
287    #[test]
288    fn test_is_circuit_open_true() {
289        let e = AgentRuntimeError::CircuitOpen { service: "svc".into() };
290        assert!(e.is_circuit_open());
291        assert!(!e.is_backpressure());
292        assert!(!e.is_provider());
293        assert!(!e.is_validation());
294        assert!(!e.is_memory());
295        assert!(!e.is_graph());
296    }
297
298    #[test]
299    fn test_is_backpressure_true() {
300        let e = AgentRuntimeError::BackpressureShed { depth: 5, capacity: 5 };
301        assert!(e.is_backpressure());
302        assert!(!e.is_circuit_open());
303    }
304
305    #[test]
306    fn test_is_provider_true() {
307        let e = AgentRuntimeError::Provider("timeout".into());
308        assert!(e.is_provider());
309        assert!(!e.is_memory());
310    }
311
312    #[test]
313    fn test_is_validation_true() {
314        let e = AgentRuntimeError::Validation {
315            field: "x".into(),
316            code: "bad".into(),
317            message: "msg".into(),
318        };
319        assert!(e.is_validation());
320        assert!(!e.is_graph());
321    }
322
323    #[test]
324    fn test_is_memory_true() {
325        let e = AgentRuntimeError::Memory("oom".into());
326        assert!(e.is_memory());
327        assert!(!e.is_validation());
328    }
329
330    #[test]
331    fn test_is_graph_true() {
332        let e = AgentRuntimeError::Graph("no such entity".into());
333        assert!(e.is_graph());
334        assert!(!e.is_memory());
335    }
336
337    // ── Round 19: untested predicates ────────────────────────────────────────
338
339    #[test]
340    fn test_is_persistence_true() {
341        let e = AgentRuntimeError::Persistence("disk full".into());
342        assert!(e.is_persistence());
343        assert!(!e.is_memory());
344    }
345
346    #[test]
347    fn test_is_not_configured_true() {
348        let e = AgentRuntimeError::NotConfigured("graph");
349        assert!(e.is_not_configured());
350        assert!(!e.is_persistence());
351    }
352
353    #[test]
354    fn test_is_deduplication_conflict_true() {
355        let e = AgentRuntimeError::DeduplicationConflict { key: "req-1".into() };
356        assert!(e.is_deduplication_conflict());
357        assert!(!e.is_circuit_open());
358    }
359
360    #[test]
361    fn test_is_retryable_true_for_provider() {
362        let e = AgentRuntimeError::Provider("503".into());
363        assert!(e.is_retryable());
364    }
365
366    #[test]
367    fn test_is_retryable_true_for_persistence() {
368        let e = AgentRuntimeError::Persistence("io error".into());
369        assert!(e.is_retryable());
370    }
371
372    #[test]
373    fn test_is_retryable_false_for_logic_errors() {
374        assert!(!AgentRuntimeError::Memory("x".into()).is_retryable());
375        assert!(!AgentRuntimeError::Graph("x".into()).is_retryable());
376        assert!(!AgentRuntimeError::Orchestration("x".into()).is_retryable());
377        assert!(!AgentRuntimeError::CircuitOpen { service: "s".into() }.is_retryable());
378    }
379
380    #[test]
381    fn test_from_serde_json_error() {
382        let json_err = serde_json::from_str::<serde_json::Value>("{invalid}").unwrap_err();
383        let e = AgentRuntimeError::from(json_err);
384        assert!(matches!(e, AgentRuntimeError::AgentLoop(_)));
385    }
386
387    #[test]
388    fn test_provider_error_display() {
389        let e = AgentRuntimeError::Provider("rate limited".into());
390        assert!(e.to_string().contains("rate limited"));
391    }
392
393    #[test]
394    fn test_persistence_error_display() {
395        let e = AgentRuntimeError::Persistence("file not found".into());
396        assert!(e.to_string().contains("file not found"));
397    }
398
399    // ── Round 28: is_agent_loop, is_orchestration ─────────────────────────────
400
401    #[test]
402    fn test_is_agent_loop_true_for_agent_loop_variant() {
403        let e = AgentRuntimeError::AgentLoop("step failed".into());
404        assert!(e.is_agent_loop());
405    }
406
407    #[test]
408    fn test_is_agent_loop_false_for_other_variants() {
409        let e = AgentRuntimeError::Memory("oom".into());
410        assert!(!e.is_agent_loop());
411    }
412
413    #[test]
414    fn test_is_orchestration_true_for_orchestration_variant() {
415        let e = AgentRuntimeError::Orchestration("pipeline stalled".into());
416        assert!(e.is_orchestration());
417    }
418
419    #[test]
420    fn test_is_orchestration_false_for_other_variants() {
421        let e = AgentRuntimeError::Graph("cycle".into());
422        assert!(!e.is_orchestration());
423    }
424
425    // ── Round 40: From<Box<dyn Error + Send + Sync>> ──────────────────────────
426
427    #[test]
428    fn test_from_boxed_error_produces_agent_loop_variant() {
429        let boxed: Box<dyn std::error::Error + Send + Sync> =
430            Box::new(std::io::Error::new(std::io::ErrorKind::Other, "generic failure"));
431        let e = AgentRuntimeError::from(boxed);
432        assert!(matches!(e, AgentRuntimeError::AgentLoop(_)));
433        assert!(e.to_string().contains("generic failure"));
434    }
435
436    #[test]
437    fn test_from_boxed_error_preserves_message() {
438        let boxed: Box<dyn std::error::Error + Send + Sync> =
439            "custom error message".parse::<i32>().unwrap_err().into();
440        let e = AgentRuntimeError::from(boxed);
441        assert!(e.is_agent_loop());
442    }
443
444    // ── Round 44: message() ───────────────────────────────────────────────────
445
446    #[test]
447    fn test_message_returns_inner_string_for_memory_variant() {
448        let e = AgentRuntimeError::Memory("store full".into());
449        assert_eq!(e.message(), "store full");
450    }
451
452    #[test]
453    fn test_message_returns_inner_string_for_provider_variant() {
454        let e = AgentRuntimeError::Provider("timeout".into());
455        assert_eq!(e.message(), "timeout");
456    }
457
458    #[test]
459    fn test_message_returns_structured_text_for_circuit_open() {
460        let e = AgentRuntimeError::CircuitOpen { service: "llm".into() };
461        assert!(e.message().contains("llm"));
462    }
463
464    #[test]
465    fn test_message_returns_structured_text_for_validation() {
466        let e = AgentRuntimeError::Validation {
467            field: "n".into(),
468            code: "range".into(),
469            message: "must be positive".into(),
470        };
471        let msg = e.message();
472        assert!(msg.contains("n") && msg.contains("must be positive"));
473    }
474
475    #[test]
476    fn test_message_returns_structured_text_for_backpressure() {
477        let e = AgentRuntimeError::BackpressureShed { depth: 10, capacity: 10 };
478        let msg = e.message();
479        assert!(msg.contains("10"));
480    }
481}