Skip to main content

klieo_core/
error.rs

1//! Error types used across `klieo-core`.
2//!
3//! Public API of every foundation crate surfaces only `Error`. Each domain
4//! has its own sub-error type (e.g. `LlmError`) that converts via
5//! `#[from]`. This keeps the public surface narrow while preserving cause
6//! chains.
7//!
8//! Sub-errors classify themselves as `retryable()` so the runtime can
9//! apply exponential backoff to transient failures only.
10
11use thiserror::Error;
12
13/// Top-level error returned by `klieo-core` runtime calls.
14///
15/// Marked `#[non_exhaustive]` so additional variants can be introduced
16/// without a major-version bump on impl crates that match on the enum.
17///
18/// ```
19/// use klieo_core::error::{Error, LlmError};
20/// let e: Error = LlmError::Timeout.into();
21/// assert!(matches!(e, Error::Llm(_)));
22/// ```
23#[derive(Debug, Error)]
24#[non_exhaustive]
25pub enum Error {
26    /// Underlying LLM provider failure.
27    #[error("LLM error: {0}")]
28    Llm(#[from] LlmError),
29    /// Tool invocation failure.
30    #[error("Tool error: {0}")]
31    Tool(#[from] ToolError),
32    /// Memory persistence failure.
33    #[error("Memory error: {0}")]
34    Memory(#[from] MemoryError),
35    /// Inter-agent bus failure.
36    #[error("Bus error: {0}")]
37    Bus(#[from] BusError),
38    /// Runtime ran the maximum allowed number of LLM/tool steps.
39    #[error("max steps exceeded ({steps})")]
40    MaxStepsExceeded {
41        /// Step count that was exceeded.
42        steps: u32,
43    },
44    /// Cooperatively cancelled.
45    #[error("cancelled")]
46    Cancelled,
47    /// Configuration validation failure.
48    #[error("config error: {0}")]
49    Config(#[from] ConfigError),
50    /// LLM reply could not be parsed into the requested typed shape.
51    ///
52    /// Surfaced from the structured-output parser when the raw text
53    /// content fails JSON deserialization. Always permanent — retrying
54    /// the same reply will fail identically.
55    #[error("bad response: {0}")]
56    BadResponse(String),
57    /// Caller-installed guardrail refused the LLM call.
58    #[error("guardrail refused: {reason}")]
59    Refused {
60        /// Human-readable reason supplied by the guardrail.
61        reason: String,
62    },
63    /// Caller-installed guardrail requested a handoff to another agent.
64    #[error("guardrail handoff to {agent}: {reason}")]
65    Handoff {
66        /// Name of the agent the guardrail requested.
67        agent: String,
68        /// Human-readable reason for the handoff.
69        reason: String,
70    },
71    /// `App` builder rejected a `build()` call because a required port
72    /// was not configured. `missing` names the port (`"llm"`,
73    /// `"memory"`, `"bus"`, `"tools"`) so the caller can point at the
74    /// specific setter to call.
75    #[error("App build error: missing {missing}")]
76    AppBuildError {
77        /// Stable identifier for the missing port.
78        missing: &'static str,
79    },
80    /// A configured model is past its sunset date in the active model registry.
81    #[error("model {model} sunset (since {since})")]
82    ModelSunset {
83        /// Display form of the offending pin (`provider/id@version`).
84        model: String,
85        /// Sunset date (registry-reported), as a string.
86        since: String,
87    },
88    /// A downstream or domain error that doesn't fit another variant.
89    ///
90    /// Use this to wrap errors from non-klieo code:
91    /// ```
92    /// use klieo_core::error::Error;
93    /// let my_err = std::io::Error::other("db gone");
94    /// let e = Error::Downstream(Box::new(my_err));
95    /// assert!(std::error::Error::source(&e).is_some());
96    /// ```
97    #[error("downstream error: {0}")]
98    Downstream(#[source] Box<dyn std::error::Error + Send + Sync>),
99    /// Run suspended at a step awaiting human approval (ADR-045). Carries the
100    /// checkpoint needed to resume via `runtime::resume_from_checkpoint`.
101    #[error("run suspended: {reason}")]
102    Suspended {
103        /// Serializable continuation state; boxed to keep `Error` small.
104        checkpoint: Box<crate::checkpoint::RunCheckpoint>,
105        /// Human-readable reason the run paused (from the `ReviewPolicy`).
106        reason: String,
107    },
108    /// A retried resume refused to re-dispatch a non-idempotent tool call
109    /// (ADR-045 fail-closed). A cross-process resume that has already been
110    /// attempted cannot prove the pending call did not fire before the crash,
111    /// so the framework will not risk a duplicate side effect (e.g. a double
112    /// payout). The checkpoint is left intact for operator reconciliation.
113    #[error("resume blocked for run {run_id}: non-idempotent tool(s) {tools:?} already attempted; manual reconciliation required")]
114    ResumeReplayBlocked {
115        /// Key under which the un-resumed checkpoint stays for reconciliation.
116        run_id: crate::ids::RunId,
117        /// Pending tools that could not be proven un-fired, hence the refusal.
118        tools: Vec<String>,
119    },
120    /// Generic wrap for errors produced by downstream consumers that
121    /// don't fit any of the typed variants above (agent-impl errors
122    /// from `klieo-spec::QualityLoop`, `klieo-flows::FlowError`, custom
123    /// domain errors).
124    ///
125    /// Carries the original error as `#[source]` so `e.source()`
126    /// traversal preserves the cause chain. Prefer the typed variants
127    /// (`Llm`, `Tool`, `Memory`, `Bus`, `Config`) when the error class
128    /// is known.
129    #[error("{message}")]
130    Other {
131        /// Human-readable context describing where the wrap happened.
132        message: String,
133        /// Underlying error preserved for `std::error::Error::source()`.
134        #[source]
135        source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
136    },
137}
138
139impl Error {
140    /// One-line constructor for [`Error::Other`] that boxes the source
141    /// into the `#[source]` chain.
142    ///
143    /// ```
144    /// use klieo_core::error::Error;
145    /// let io = std::io::Error::other("disk gone");
146    /// let e = Error::wrap("ledger write failed", io);
147    /// assert_eq!(e.to_string(), "ledger write failed");
148    /// assert!(std::error::Error::source(&e).is_some());
149    /// ```
150    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    /// Whether the operation is safe to retry without changing inputs.
161    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/// LLM provider errors.
304///
305/// Marked `#[non_exhaustive]` so additional variants can be introduced
306/// without a major-version bump on impl crates that match on the enum.
307#[derive(Debug, Error)]
308#[non_exhaustive]
309pub enum LlmError {
310    /// Network transport failure (connection refused, DNS, etc).
311    #[error("network error: {0}")]
312    Network(String),
313    /// Request exceeded its deadline.
314    #[error("timeout")]
315    Timeout,
316    /// Provider returned a 429-equivalent rate-limit signal.
317    #[error("rate limited (retry after {retry_after_secs}s)")]
318    RateLimit {
319        /// Server-suggested retry delay.
320        retry_after_secs: u32,
321    },
322    /// Authentication or authorisation failure.
323    #[error("unauthorized")]
324    Unauthorized,
325    /// Provider rejected the request shape.
326    #[error("bad request: {0}")]
327    BadRequest(String),
328    /// Provider returned a 5xx-equivalent.
329    #[error("server error: {0}")]
330    Server(String),
331    /// Response payload could not be decoded.
332    #[error("decoding error: {0}")]
333    Decoding(String),
334    /// Capability declared unsupported by the client.
335    #[error("unsupported capability: {0}")]
336    Unsupported(String),
337    /// Operation was cooperatively cancelled. Surfaced from the
338    /// streaming runtime when `ctx.cancel` fires mid-stream so consumers
339    /// can distinguish a cooperative cancel from a provider failure.
340    #[error("operation cancelled")]
341    Cancelled,
342    /// The run suspended at a human-review gate (ADR-045) rather than
343    /// failing. Emitted as the streaming path's terminal item when a
344    /// `ReviewPolicy` pauses a step: the durable `RunCheckpoint` was
345    /// persisted to `RunOptions::checkpoint_kv_bucket`, and the run resumes
346    /// via `resume_from_checkpoint`. Not retryable — and not a failure, so the
347    /// streaming driver records no `Episode::Failed` for it.
348    #[error("suspended for human review: {reason}")]
349    Suspended {
350        /// Short, human-readable pause explanation — the same string carried by
351        /// the blocking path's `Error::Suspended`, safe to surface to operators.
352        reason: String,
353    },
354}
355
356impl LlmError {
357    /// Returns true if the runtime should retry the operation.
358    pub fn retryable(&self) -> bool {
359        matches!(
360            self,
361            Self::Network(_) | Self::Timeout | Self::RateLimit { .. } | Self::Server(_)
362        )
363    }
364}
365
366/// Tool invocation errors.
367#[derive(Debug, Error)]
368#[non_exhaustive]
369pub enum ToolError {
370    /// Tool name not registered with the invoker.
371    #[error("unknown tool: {0}")]
372    UnknownTool(String),
373    /// Arguments did not match the declared JSON-schema.
374    #[error("invalid args: {0}")]
375    InvalidArgs(String),
376    /// Tool returned a transient failure; runtime may retry.
377    #[error("retryable: {message} (retry after {retry_after_secs}s)")]
378    Retryable {
379        /// Human-readable reason.
380        message: String,
381        /// Suggested delay before retry.
382        retry_after_secs: u32,
383    },
384    /// Tool returned a permanent failure.
385    #[error("permanent: {0}")]
386    Permanent(String),
387    /// Tool execution exceeded its timeout.
388    #[error("timeout")]
389    Timeout,
390    /// Tool invocation was cooperatively cancelled (e.g. HTTP/SSE client
391    /// disconnected mid-stream).
392    #[error("cancelled")]
393    Cancelled,
394}
395
396impl ToolError {
397    /// Returns true if the runtime should retry the tool call.
398    pub fn retryable(&self) -> bool {
399        matches!(self, Self::Retryable { .. } | Self::Timeout)
400    }
401}
402
403/// Memory persistence errors.
404#[derive(Debug, Error)]
405#[non_exhaustive]
406pub enum MemoryError {
407    /// Underlying store rejected the operation.
408    #[error("store error: {0}")]
409    Store(String),
410    /// Requested resource not present.
411    #[error("not found")]
412    NotFound,
413    /// Embedding generation failed.
414    #[error("embedding failed: {0}")]
415    Embedding(String),
416    /// Serialization failure.
417    #[error("serialization: {0}")]
418    Serialization(String),
419}
420
421/// Inter-agent bus errors.
422#[derive(Debug, Error)]
423#[non_exhaustive]
424pub enum BusError {
425    /// Network or broker connectivity failure.
426    #[error("connection error: {0}")]
427    Connection(String),
428    /// Subject / queue / bucket not found.
429    #[error("not found: {0}")]
430    NotFound(String),
431    /// CAS revision mismatch.
432    #[error("cas conflict (expected {expected}, got {actual})")]
433    CasConflict {
434        /// Caller's expected revision.
435        expected: u64,
436        /// Actual current revision.
437        actual: u64,
438    },
439    /// Operation exceeded its deadline.
440    #[error("timeout")]
441    Timeout,
442    /// Generic transient failure flagged by impl as retryable.
443    #[error("retryable: {0}")]
444    Retryable(String),
445    /// Generic permanent failure.
446    #[error("permanent: {0}")]
447    Permanent(String),
448    /// Operation not implemented by this `KvStore` backend.
449    #[error("unsupported operation: {0}")]
450    Unsupported(String),
451    /// Caller supplied an invalid argument that the bus refuses to
452    /// accept (e.g. a subject segment containing reserved
453    /// metacharacters). Permanent — retry would fail identically.
454    #[error("invalid argument: {0}")]
455    Invalid(String),
456}
457
458impl BusError {
459    /// Returns true if the runtime should retry the operation.
460    pub fn retryable(&self) -> bool {
461        matches!(
462            self,
463            Self::Connection(_) | Self::Timeout | Self::Retryable(_)
464        )
465    }
466}
467
468/// Configuration validation errors.
469#[derive(Debug, Error)]
470#[non_exhaustive]
471pub enum ConfigError {
472    /// Required configuration key absent.
473    #[error("missing required key: {0}")]
474    MissingKey(String),
475    /// Configuration value failed validation.
476    #[error("invalid value for {key}: {reason}")]
477    InvalidValue {
478        /// Key that was invalid.
479        key: String,
480        /// Reason it was invalid.
481        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}