klieo-core 0.36.0

Core traits + runtime for the klieo agent framework.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
//! Error types used across `klieo-core`.
//!
//! Public API of every foundation crate surfaces only `Error`. Each domain
//! has its own sub-error type (e.g. `LlmError`) that converts via
//! `#[from]`. This keeps the public surface narrow while preserving cause
//! chains.
//!
//! Sub-errors classify themselves as `retryable()` so the runtime can
//! apply exponential backoff to transient failures only.

use thiserror::Error;

/// Top-level error returned by `klieo-core` runtime calls.
///
/// Marked `#[non_exhaustive]` so additional variants can be introduced
/// without a major-version bump on impl crates that match on the enum.
///
/// ```
/// use klieo_core::error::{Error, LlmError};
/// let e: Error = LlmError::Timeout.into();
/// assert!(matches!(e, Error::Llm(_)));
/// ```
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum Error {
    /// Underlying LLM provider failure.
    #[error("LLM error: {0}")]
    Llm(#[from] LlmError),
    /// Tool invocation failure.
    #[error("Tool error: {0}")]
    Tool(#[from] ToolError),
    /// Memory persistence failure.
    #[error("Memory error: {0}")]
    Memory(#[from] MemoryError),
    /// Inter-agent bus failure.
    #[error("Bus error: {0}")]
    Bus(#[from] BusError),
    /// Runtime ran the maximum allowed number of LLM/tool steps.
    #[error("max steps exceeded ({steps})")]
    MaxStepsExceeded {
        /// Step count that was exceeded.
        steps: u32,
    },
    /// Cooperatively cancelled.
    #[error("cancelled")]
    Cancelled,
    /// Configuration validation failure.
    #[error("config error: {0}")]
    Config(#[from] ConfigError),
    /// LLM reply could not be parsed into the requested typed shape.
    ///
    /// Surfaced from the structured-output parser when the raw text
    /// content fails JSON deserialization. Always permanent — retrying
    /// the same reply will fail identically.
    #[error("bad response: {0}")]
    BadResponse(String),
    /// Caller-installed guardrail refused the LLM call.
    #[error("guardrail refused: {reason}")]
    Refused {
        /// Human-readable reason supplied by the guardrail.
        reason: String,
    },
    /// Caller-installed guardrail requested a handoff to another agent.
    #[error("guardrail handoff to {agent}: {reason}")]
    Handoff {
        /// Name of the agent the guardrail requested.
        agent: String,
        /// Human-readable reason for the handoff.
        reason: String,
    },
    /// `App` builder rejected a `build()` call because a required port
    /// was not configured. `missing` names the port (`"llm"`,
    /// `"memory"`, `"bus"`, `"tools"`) so the caller can point at the
    /// specific setter to call.
    #[error("App build error: missing {missing}")]
    AppBuildError {
        /// Stable identifier for the missing port.
        missing: &'static str,
    },
    /// Generic wrap for errors produced by downstream consumers that
    /// don't fit any of the typed variants above (agent-impl errors
    /// from `klieo-spec::QualityLoop`, `klieo-flows::FlowError`, custom
    /// domain errors).
    ///
    /// Carries the original error as `#[source]` so `e.source()`
    /// traversal preserves the cause chain. Prefer the typed variants
    /// (`Llm`, `Tool`, `Memory`, `Bus`, `Config`) when the error class
    /// is known.
    #[error("{message}")]
    Other {
        /// Human-readable context describing where the wrap happened.
        message: String,
        /// Underlying error preserved for `std::error::Error::source()`.
        #[source]
        source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
    },
}

impl Error {
    /// One-line constructor for [`Error::Other`] that boxes the source
    /// into the `#[source]` chain.
    ///
    /// ```
    /// use klieo_core::error::Error;
    /// let io = std::io::Error::other("disk gone");
    /// let e = Error::wrap("ledger write failed", io);
    /// assert_eq!(e.to_string(), "ledger write failed");
    /// assert!(std::error::Error::source(&e).is_some());
    /// ```
    pub fn wrap<E>(message: impl Into<String>, source: E) -> Self
    where
        E: std::error::Error + Send + Sync + 'static,
    {
        Self::Other {
            message: message.into(),
            source: Some(Box::new(source)),
        }
    }

    /// Whether the operation is safe to retry without changing inputs.
    pub fn retryable(&self) -> bool {
        match self {
            Self::Llm(e) => e.retryable(),
            Self::Tool(e) => e.retryable(),
            Self::Bus(e) => e.retryable(),
            Self::Memory(_)
            | Self::MaxStepsExceeded { .. }
            | Self::Cancelled
            | Self::Config(_)
            | Self::BadResponse(_)
            | Self::Refused { .. }
            | Self::Handoff { .. }
            | Self::AppBuildError { .. }
            | Self::Other { .. } => false,
        }
    }
}

#[cfg(test)]
mod other_variant_tests {
    use super::*;
    use std::error::Error as StdError;

    #[test]
    fn other_message_surfaces_through_display() {
        let e = Error::Other {
            message: "quality loop: short draft".into(),
            source: None,
        };
        assert_eq!(e.to_string(), "quality loop: short draft");
    }

    #[test]
    fn other_preserves_source_chain() {
        let inner = std::io::Error::other("disk gone");
        let e = Error::Other {
            message: "ledger write failed".into(),
            source: Some(Box::new(inner)),
        };
        let src = e.source().expect("source must be preserved");
        assert_eq!(src.to_string(), "disk gone");
    }

    #[test]
    fn other_without_source_returns_none() {
        let e = Error::Other {
            message: "no inner".into(),
            source: None,
        };
        assert!(e.source().is_none());
    }

    #[test]
    fn wrap_ctor_boxes_source_and_carries_message() {
        let inner = std::io::Error::other("disk gone");
        let e = Error::wrap("ledger write failed", inner);
        assert_eq!(e.to_string(), "ledger write failed");
        let src = e.source().expect("source must be preserved by wrap()");
        assert_eq!(src.to_string(), "disk gone");
    }

    #[test]
    fn other_is_not_retryable() {
        let with_source = Error::Other {
            message: "x".into(),
            source: Some(Box::new(std::io::Error::other("y"))),
        };
        let without_source = Error::Other {
            message: "x".into(),
            source: None,
        };
        assert!(!with_source.retryable());
        assert!(!without_source.retryable());
    }
}

/// LLM provider errors.
///
/// Marked `#[non_exhaustive]` so additional variants can be introduced
/// without a major-version bump on impl crates that match on the enum.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum LlmError {
    /// Network transport failure (connection refused, DNS, etc).
    #[error("network error: {0}")]
    Network(String),
    /// Request exceeded its deadline.
    #[error("timeout")]
    Timeout,
    /// Provider returned a 429-equivalent rate-limit signal.
    #[error("rate limited (retry after {retry_after_secs}s)")]
    RateLimit {
        /// Server-suggested retry delay.
        retry_after_secs: u32,
    },
    /// Authentication or authorisation failure.
    #[error("unauthorized")]
    Unauthorized,
    /// Provider rejected the request shape.
    #[error("bad request: {0}")]
    BadRequest(String),
    /// Provider returned a 5xx-equivalent.
    #[error("server error: {0}")]
    Server(String),
    /// Response payload could not be decoded.
    #[error("decoding error: {0}")]
    Decoding(String),
    /// Capability declared unsupported by the client.
    #[error("unsupported capability: {0}")]
    Unsupported(String),
    /// Operation was cooperatively cancelled. Surfaced from the
    /// streaming runtime when `ctx.cancel` fires mid-stream so consumers
    /// can distinguish a cooperative cancel from a provider failure.
    #[error("operation cancelled")]
    Cancelled,
}

impl LlmError {
    /// Returns true if the runtime should retry the operation.
    pub fn retryable(&self) -> bool {
        matches!(
            self,
            Self::Network(_) | Self::Timeout | Self::RateLimit { .. } | Self::Server(_)
        )
    }
}

/// Tool invocation errors.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum ToolError {
    /// Tool name not registered with the invoker.
    #[error("unknown tool: {0}")]
    UnknownTool(String),
    /// Arguments did not match the declared JSON-schema.
    #[error("invalid args: {0}")]
    InvalidArgs(String),
    /// Tool returned a transient failure; runtime may retry.
    #[error("retryable: {message} (retry after {retry_after_secs}s)")]
    Retryable {
        /// Human-readable reason.
        message: String,
        /// Suggested delay before retry.
        retry_after_secs: u32,
    },
    /// Tool returned a permanent failure.
    #[error("permanent: {0}")]
    Permanent(String),
    /// Tool execution exceeded its timeout.
    #[error("timeout")]
    Timeout,
    /// Tool invocation was cooperatively cancelled (e.g. HTTP/SSE client
    /// disconnected mid-stream).
    #[error("cancelled")]
    Cancelled,
}

impl ToolError {
    /// Returns true if the runtime should retry the tool call.
    pub fn retryable(&self) -> bool {
        matches!(self, Self::Retryable { .. } | Self::Timeout)
    }
}

/// Memory persistence errors.
#[derive(Debug, Error)]
pub enum MemoryError {
    /// Underlying store rejected the operation.
    #[error("store error: {0}")]
    Store(String),
    /// Requested resource not present.
    #[error("not found")]
    NotFound,
    /// Embedding generation failed.
    #[error("embedding failed: {0}")]
    Embedding(String),
    /// Serialization failure.
    #[error("serialization: {0}")]
    Serialization(String),
}

/// Inter-agent bus errors.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum BusError {
    /// Network or broker connectivity failure.
    #[error("connection error: {0}")]
    Connection(String),
    /// Subject / queue / bucket not found.
    #[error("not found: {0}")]
    NotFound(String),
    /// CAS revision mismatch.
    #[error("cas conflict (expected {expected}, got {actual})")]
    CasConflict {
        /// Caller's expected revision.
        expected: u64,
        /// Actual current revision.
        actual: u64,
    },
    /// Operation exceeded its deadline.
    #[error("timeout")]
    Timeout,
    /// Generic transient failure flagged by impl as retryable.
    #[error("retryable: {0}")]
    Retryable(String),
    /// Generic permanent failure.
    #[error("permanent: {0}")]
    Permanent(String),
    /// Operation not implemented by this `KvStore` backend.
    #[error("unsupported operation: {0}")]
    Unsupported(String),
    /// Caller supplied an invalid argument that the bus refuses to
    /// accept (e.g. a subject segment containing reserved
    /// metacharacters). Permanent — retry would fail identically.
    #[error("invalid argument: {0}")]
    Invalid(String),
}

impl BusError {
    /// Returns true if the runtime should retry the operation.
    pub fn retryable(&self) -> bool {
        matches!(
            self,
            Self::Connection(_) | Self::Timeout | Self::Retryable(_)
        )
    }
}

/// Configuration validation errors.
#[derive(Debug, Error)]
pub enum ConfigError {
    /// Required configuration key absent.
    #[error("missing required key: {0}")]
    MissingKey(String),
    /// Configuration value failed validation.
    #[error("invalid value for {key}: {reason}")]
    InvalidValue {
        /// Key that was invalid.
        key: String,
        /// Reason it was invalid.
        reason: String,
    },
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn llm_timeout_is_retryable() {
        let e: Error = LlmError::Timeout.into();
        assert!(e.retryable());
    }

    #[test]
    fn llm_unauthorized_is_not_retryable() {
        let e: Error = LlmError::Unauthorized.into();
        assert!(!e.retryable());
    }

    #[test]
    fn tool_invalid_args_is_not_retryable() {
        let e: Error = ToolError::InvalidArgs("bad json".into()).into();
        assert!(!e.retryable());
    }

    #[test]
    fn config_error_is_not_retryable() {
        let e: Error = ConfigError::MissingKey("x".into()).into();
        assert!(!e.retryable());
    }

    #[test]
    fn refused_is_not_retryable() {
        let e = Error::Refused {
            reason: "policy".into(),
        };
        assert!(!e.retryable());
    }

    #[test]
    fn handoff_is_not_retryable() {
        let e = Error::Handoff {
            agent: "specialist".into(),
            reason: "out of scope".into(),
        };
        assert!(!e.retryable());
    }

    #[test]
    fn cancelled_variant_renders_stable_message() {
        let e = ToolError::Cancelled;
        assert_eq!(e.to_string(), "cancelled");
        assert!(!e.retryable());
    }
}