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
150impl From<serde_json::Error> for AgentRuntimeError {
151    fn from(e: serde_json::Error) -> Self {
152        AgentRuntimeError::AgentLoop(format!("JSON error: {e}"))
153    }
154}
155
156impl From<std::io::Error> for AgentRuntimeError {
157    fn from(e: std::io::Error) -> Self {
158        AgentRuntimeError::Persistence(format!("I/O error: {e}"))
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn test_memory_error_display() {
168        let e = AgentRuntimeError::Memory("store full".into());
169        assert_eq!(e.to_string(), "Memory operation failed: store full");
170    }
171
172    #[test]
173    fn test_graph_error_display() {
174        let e = AgentRuntimeError::Graph("entity not found".into());
175        assert_eq!(e.to_string(), "Graph operation failed: entity not found");
176    }
177
178    #[test]
179    fn test_orchestration_error_display() {
180        let e = AgentRuntimeError::Orchestration("pipeline stalled".into());
181        assert_eq!(e.to_string(), "Orchestration failed: pipeline stalled");
182    }
183
184    #[test]
185    fn test_agent_loop_error_display() {
186        let e = AgentRuntimeError::AgentLoop("max iterations".into());
187        assert_eq!(e.to_string(), "Agent loop error: max iterations");
188    }
189
190    #[test]
191    fn test_not_configured_error_display() {
192        let e = AgentRuntimeError::NotConfigured("memory");
193        assert_eq!(e.to_string(), "Runtime not configured: missing 'memory'");
194    }
195
196    #[test]
197    fn test_circuit_open_error_display() {
198        let e = AgentRuntimeError::CircuitOpen {
199            service: "llm-api".into(),
200        };
201        assert_eq!(e.to_string(), "Circuit breaker open for 'llm-api'");
202    }
203
204    #[test]
205    fn test_backpressure_shed_error_display() {
206        let e = AgentRuntimeError::BackpressureShed {
207            depth: 100,
208            capacity: 100,
209        };
210        assert_eq!(
211            e.to_string(),
212            "Backpressure threshold exceeded: queue depth 100/100"
213        );
214    }
215
216    #[test]
217    fn test_deduplication_conflict_display() {
218        let e = AgentRuntimeError::DeduplicationConflict {
219            key: "abc123".into(),
220        };
221        assert_eq!(e.to_string(), "Deduplication key collision: abc123");
222    }
223
224    #[test]
225    fn test_error_is_send_sync() {
226        fn assert_send_sync<T: Send + Sync>() {}
227        assert_send_sync::<AgentRuntimeError>();
228    }
229
230    #[test]
231    fn test_error_debug_format() {
232        let e = AgentRuntimeError::Memory("test".into());
233        let debug = format!("{:?}", e);
234        assert!(debug.contains("Memory"));
235    }
236
237    #[test]
238    fn test_validation_error_display() {
239        let e = AgentRuntimeError::Validation {
240            field: "n".into(),
241            code: "out_of_range".into(),
242            message: "n must be between 1 and 100".into(),
243        };
244        assert_eq!(
245            e.to_string(),
246            "Validation failed for field 'n': [out_of_range] n must be between 1 and 100"
247        );
248    }
249
250    #[test]
251    fn test_is_circuit_open_true() {
252        let e = AgentRuntimeError::CircuitOpen { service: "svc".into() };
253        assert!(e.is_circuit_open());
254        assert!(!e.is_backpressure());
255        assert!(!e.is_provider());
256        assert!(!e.is_validation());
257        assert!(!e.is_memory());
258        assert!(!e.is_graph());
259    }
260
261    #[test]
262    fn test_is_backpressure_true() {
263        let e = AgentRuntimeError::BackpressureShed { depth: 5, capacity: 5 };
264        assert!(e.is_backpressure());
265        assert!(!e.is_circuit_open());
266    }
267
268    #[test]
269    fn test_is_provider_true() {
270        let e = AgentRuntimeError::Provider("timeout".into());
271        assert!(e.is_provider());
272        assert!(!e.is_memory());
273    }
274
275    #[test]
276    fn test_is_validation_true() {
277        let e = AgentRuntimeError::Validation {
278            field: "x".into(),
279            code: "bad".into(),
280            message: "msg".into(),
281        };
282        assert!(e.is_validation());
283        assert!(!e.is_graph());
284    }
285
286    #[test]
287    fn test_is_memory_true() {
288        let e = AgentRuntimeError::Memory("oom".into());
289        assert!(e.is_memory());
290        assert!(!e.is_validation());
291    }
292
293    #[test]
294    fn test_is_graph_true() {
295        let e = AgentRuntimeError::Graph("no such entity".into());
296        assert!(e.is_graph());
297        assert!(!e.is_memory());
298    }
299
300    // ── Round 19: untested predicates ────────────────────────────────────────
301
302    #[test]
303    fn test_is_persistence_true() {
304        let e = AgentRuntimeError::Persistence("disk full".into());
305        assert!(e.is_persistence());
306        assert!(!e.is_memory());
307    }
308
309    #[test]
310    fn test_is_not_configured_true() {
311        let e = AgentRuntimeError::NotConfigured("graph");
312        assert!(e.is_not_configured());
313        assert!(!e.is_persistence());
314    }
315
316    #[test]
317    fn test_is_deduplication_conflict_true() {
318        let e = AgentRuntimeError::DeduplicationConflict { key: "req-1".into() };
319        assert!(e.is_deduplication_conflict());
320        assert!(!e.is_circuit_open());
321    }
322
323    #[test]
324    fn test_is_retryable_true_for_provider() {
325        let e = AgentRuntimeError::Provider("503".into());
326        assert!(e.is_retryable());
327    }
328
329    #[test]
330    fn test_is_retryable_true_for_persistence() {
331        let e = AgentRuntimeError::Persistence("io error".into());
332        assert!(e.is_retryable());
333    }
334
335    #[test]
336    fn test_is_retryable_false_for_logic_errors() {
337        assert!(!AgentRuntimeError::Memory("x".into()).is_retryable());
338        assert!(!AgentRuntimeError::Graph("x".into()).is_retryable());
339        assert!(!AgentRuntimeError::Orchestration("x".into()).is_retryable());
340        assert!(!AgentRuntimeError::CircuitOpen { service: "s".into() }.is_retryable());
341    }
342
343    #[test]
344    fn test_from_serde_json_error() {
345        let json_err = serde_json::from_str::<serde_json::Value>("{invalid}").unwrap_err();
346        let e = AgentRuntimeError::from(json_err);
347        assert!(matches!(e, AgentRuntimeError::AgentLoop(_)));
348    }
349
350    #[test]
351    fn test_provider_error_display() {
352        let e = AgentRuntimeError::Provider("rate limited".into());
353        assert!(e.to_string().contains("rate limited"));
354    }
355
356    #[test]
357    fn test_persistence_error_display() {
358        let e = AgentRuntimeError::Persistence("file not found".into());
359        assert!(e.to_string().contains("file not found"));
360    }
361
362    // ── Round 28: is_agent_loop, is_orchestration ─────────────────────────────
363
364    #[test]
365    fn test_is_agent_loop_true_for_agent_loop_variant() {
366        let e = AgentRuntimeError::AgentLoop("step failed".into());
367        assert!(e.is_agent_loop());
368    }
369
370    #[test]
371    fn test_is_agent_loop_false_for_other_variants() {
372        let e = AgentRuntimeError::Memory("oom".into());
373        assert!(!e.is_agent_loop());
374    }
375
376    #[test]
377    fn test_is_orchestration_true_for_orchestration_variant() {
378        let e = AgentRuntimeError::Orchestration("pipeline stalled".into());
379        assert!(e.is_orchestration());
380    }
381
382    #[test]
383    fn test_is_orchestration_false_for_other_variants() {
384        let e = AgentRuntimeError::Graph("cycle".into());
385        assert!(!e.is_orchestration());
386    }
387}