Skip to main content

heartbit_core/
error.rs

1//! Error type for all heartbit-core fallible operations.
2
3use std::time::Duration;
4
5use crate::types::TokenUsage;
6use thiserror::Error;
7
8/// Top-level error type for the heartbit-core crate.
9///
10/// All fallible public APIs return `Result<T, Error>`. Callers should match on
11/// specific variants rather than converting to strings so that retry logic and
12/// error reporting remain precise.
13///
14/// ## Retryable variants
15///
16/// The following variants indicate transient conditions that callers *may* retry:
17/// - [`Error::Http`] — network-level failures (connection reset, timeout, …)
18/// - [`Error::Api`] with `status >= 500` or `status == 429`
19/// - [`Error::TenantOverloaded`] — back off and retry when capacity is available
20/// - [`Error::CircuitOpen`] — retry after the `until` instant
21///
22/// ## Token accounting
23///
24/// [`Error::WithPartialUsage`] wraps any other variant and carries the token
25/// usage accumulated before the failure. Inspect it with [`Error::partial_usage`]
26/// to charge tokens even on error.
27#[derive(Error, Debug)]
28pub enum Error {
29    /// An HTTP-level error from the `reqwest` client (network failure, TLS error, etc.).
30    ///
31    /// Potentially retryable depending on the underlying cause.
32    #[error("HTTP request failed: {0}")]
33    Http(#[from] reqwest::Error),
34
35    /// JSON serialization or deserialization failed.
36    ///
37    /// Indicates a protocol mismatch or a malformed API response. Not retryable.
38    #[error("JSON serialization/deserialization failed: {0}")]
39    Json(#[from] serde_json::Error),
40
41    /// The LLM API returned a non-2xx HTTP status code.
42    ///
43    /// `status == 429` is rate-limited (retryable). `status >= 500` is a
44    /// server error (retryable). `status == 400` / `401` / `403` are not
45    /// retryable without changing the request.
46    #[error("API error ({status}): {message}")]
47    Api {
48        /// HTTP status code returned by the API.
49        status: u16,
50        /// Human-readable error message from the response body.
51        message: String,
52    },
53
54    /// A general agent-level error not covered by a more specific variant.
55    ///
56    /// Produced by tool execution failures, orchestrator logic errors, and
57    /// other agent-layer problems.
58    #[error("Agent error: {0}")]
59    Agent(String),
60
61    /// Authentication or authorization failure.
62    ///
63    /// Typically indicates a missing or invalid API key. Not retryable without
64    /// supplying valid credentials.
65    #[error("Authentication error: {0}")]
66    Auth(String),
67
68    /// The agent loop reached its configured maximum turn count without finishing.
69    ///
70    /// Not retryable — callers should increase `max_turns` or redesign the task.
71    #[error("Max turns ({0}) exceeded")]
72    MaxTurnsExceeded(usize),
73
74    /// The agent repeated the same tool call past the doom-loop hard limit and
75    /// ignored the soft warnings — the run is aborted rather than spin forever.
76    /// Not retryable; the approach is stuck and needs a human or a different
77    /// strategy.
78    #[error("Doom loop: identical tool calls repeated {0} times — aborting the run")]
79    DoomLoopAborted(u32),
80
81    /// The LLM response was cut off because `max_tokens` was reached.
82    ///
83    /// The agent loop surfaces this as an error when truncation is fatal. Callers
84    /// can increase `max_tokens` or compress context and retry.
85    #[error("Response truncated (max_tokens reached)")]
86    Truncated,
87
88    /// The agent run exceeded the configured wall-clock timeout.
89    ///
90    /// Potentially retryable with a longer timeout or a simpler task.
91    #[error("Run timed out after {0:?}")]
92    RunTimeout(Duration),
93
94    /// An error originating from the Model Context Protocol (MCP) client or server.
95    ///
96    /// Covers handshake failures, protocol violations, and tool call errors
97    /// returned by remote MCP servers.
98    #[error("MCP error: {0}")]
99    Mcp(String),
100
101    /// An error from the Agent-to-Agent (A2A) protocol layer.
102    ///
103    /// Returned when communicating with remote A2A agents fails.
104    #[error("A2A error: {0}")]
105    A2a(String),
106
107    /// An error in configuration parsing or validation.
108    ///
109    /// Produced by `HeartbitConfig` deserialization and by builder `build()` calls
110    /// that detect invalid combinations of options.
111    #[error("Configuration error: {0}")]
112    Config(String),
113
114    /// A persistence-layer error (e.g., PostgreSQL task-store failure).
115    ///
116    /// Potentially retryable on transient connection errors.
117    #[error("Store error: {0}")]
118    Store(String),
119
120    /// An error in the agent memory subsystem (recall, store, prune, etc.).
121    #[error("Memory error: {0}")]
122    Memory(String),
123
124    /// An error in the knowledge-base subsystem (indexing, chunking, search).
125    #[error("Knowledge error: {0}")]
126    Knowledge(String),
127
128    /// A guardrail denied or errored during a request.
129    ///
130    /// Produced when a [`crate::Guardrail`] hook returns `Deny` or when the
131    /// guardrail itself fails. The message contains the denial reason.
132    #[error("Guardrail error: {0}")]
133    Guardrail(String),
134
135    /// An error in the daemon execution path (Kafka consumer, dispatcher, etc.).
136    #[error("Daemon error: {0}")]
137    Daemon(String),
138
139    /// An error in the sensor pipeline (RSS, webhook, schedule triggers).
140    #[error("Sensor error: {0}")]
141    Sensor(String),
142
143    /// The agent exceeded its token budget before completing.
144    ///
145    /// `used` is the total tokens consumed; `limit` is the configured cap.
146    /// Not retryable without either increasing the budget or reducing the task.
147    #[error("Token budget exceeded: used {used}, limit {limit}")]
148    BudgetExceeded {
149        /// Total tokens consumed before the budget was exhausted.
150        used: u64,
151        /// The configured token budget that was exceeded.
152        limit: u64,
153    },
154
155    /// A workflow run hit its runaway agent backstop (total agents per run).
156    ///
157    /// Produced by the dynamic-workflow combinator core when more than `limit`
158    /// agents are issued in a single run. Distinct from [`Self::BudgetExceeded`]
159    /// (which is about tokens); this is a structural guard against runaway loops.
160    #[error("Agent backstop exceeded: limit {limit} agents per run")]
161    AgentBudgetExceeded {
162        /// The configured maximum number of agents per run.
163        limit: u64,
164    },
165
166    /// An error in the WebSocket/session channel layer.
167    #[error("Channel error: {0}")]
168    Channel(String),
169
170    /// An error originating from the Telegram bot adapter.
171    #[error("Telegram error: {0}")]
172    Telegram(String),
173
174    /// A kill switch was activated, terminating the agent run immediately.
175    ///
176    /// Produced by the kill-switch guardrail when a prohibited pattern is detected.
177    #[error("Kill switch activated: {0}")]
178    KillSwitch(String),
179
180    /// The agent attempted a filesystem operation that violates the sandbox policy.
181    ///
182    /// Produced by `CorePathPolicy::check_path` or the Landlock sandbox.
183    #[error("Sandbox violation: {0}")]
184    Sandbox(String),
185
186    /// The tenant has reached its maximum concurrent-request capacity.
187    ///
188    /// Retryable: callers should back off and retry after a delay.
189    #[error("tenant {tenant_id} overloaded: in_flight={in_flight}, cap={cap}")]
190    TenantOverloaded {
191        /// The tenant identifier that is overloaded.
192        tenant_id: String,
193        /// Number of requests currently in flight for this tenant.
194        in_flight: usize,
195        /// Maximum allowed concurrent requests for this tenant.
196        cap: usize,
197    },
198
199    /// The LLM provider's circuit breaker is open; requests are being shed.
200    ///
201    /// Retryable: callers should retry after the `until` instant has passed.
202    #[error("circuit breaker open: retry after {until:?} (prev open duration: {prev_duration:?})")]
203    CircuitOpen {
204        /// The instant after which requests should be retried.
205        until: std::time::Instant,
206        /// How long the circuit was open in the previous open window.
207        prev_duration: std::time::Duration,
208    },
209
210    /// Wraps another error with partial token usage accumulated before failure.
211    ///
212    /// Used by `AgentRunner::execute` to surface tokens consumed before an error.
213    /// Inspect partial usage with [`Error::partial_usage`]. Re-wrapping an existing
214    /// `WithPartialUsage` replaces the usage rather than nesting.
215    #[error("{source}")]
216    WithPartialUsage {
217        /// The underlying error that caused the agent run to abort.
218        #[source]
219        source: Box<Error>,
220        /// Token usage accumulated before the error occurred.
221        usage: TokenUsage,
222    },
223}
224
225impl Error {
226    /// Wrap this error with partial token usage data.
227    ///
228    /// If `self` is already `WithPartialUsage`, the inner error is unwrapped
229    /// first to prevent nesting. The new `usage` replaces the old one.
230    pub fn with_partial_usage(self, usage: TokenUsage) -> Self {
231        let inner = match self {
232            Error::WithPartialUsage { source, .. } => *source,
233            other => other,
234        };
235        Error::WithPartialUsage {
236            source: Box::new(inner),
237            usage,
238        }
239    }
240
241    /// Wrap this error with the sum of `prior` usage and the error's own partial usage.
242    ///
243    /// Shorthand for `e.with_partial_usage(prior + e.partial_usage())`.
244    pub fn accumulate_usage(self, prior: TokenUsage) -> Self {
245        let mut usage = prior;
246        usage += self.partial_usage();
247        self.with_partial_usage(usage)
248    }
249
250    /// Extract partial token usage from this error.
251    /// Returns `TokenUsage::default()` for errors that don't carry usage data.
252    pub fn partial_usage(&self) -> TokenUsage {
253        match self {
254            Error::WithPartialUsage { usage, .. } => *usage,
255            _ => TokenUsage::default(),
256        }
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    #[test]
265    fn error_display_messages() {
266        let err = Error::Api {
267            status: 429,
268            message: "rate limited".into(),
269        };
270        assert_eq!(err.to_string(), "API error (429): rate limited");
271
272        let err = Error::MaxTurnsExceeded(10);
273        assert_eq!(err.to_string(), "Max turns (10) exceeded");
274
275        let err = Error::Truncated;
276        assert_eq!(err.to_string(), "Response truncated (max_tokens reached)");
277    }
278
279    #[test]
280    fn error_auth_display_message() {
281        let err = Error::Auth("invalid token".into());
282        assert_eq!(err.to_string(), "Authentication error: invalid token");
283    }
284
285    #[test]
286    fn error_mcp_display_message() {
287        let err = Error::Mcp("connection refused".into());
288        assert_eq!(err.to_string(), "MCP error: connection refused");
289    }
290
291    #[test]
292    fn error_a2a_display_message() {
293        let err = Error::A2a("agent not found".into());
294        assert_eq!(err.to_string(), "A2A error: agent not found");
295    }
296
297    #[test]
298    fn error_store_display_message() {
299        let err = Error::Store("connection refused".into());
300        assert_eq!(err.to_string(), "Store error: connection refused");
301    }
302
303    #[test]
304    fn error_memory_display_message() {
305        let err = Error::Memory("not found".into());
306        assert_eq!(err.to_string(), "Memory error: not found");
307    }
308
309    #[test]
310    fn error_knowledge_display_message() {
311        let err = Error::Knowledge("file not found".into());
312        assert_eq!(err.to_string(), "Knowledge error: file not found");
313    }
314
315    #[test]
316    fn error_guardrail_display_message() {
317        let err = Error::Guardrail("PII detected in output".into());
318        assert_eq!(err.to_string(), "Guardrail error: PII detected in output");
319    }
320
321    #[test]
322    fn error_daemon_display_message() {
323        let err = Error::Daemon("broker connection refused".into());
324        assert_eq!(err.to_string(), "Daemon error: broker connection refused");
325    }
326
327    #[test]
328    fn error_sensor_display_message() {
329        let err = Error::Sensor("RSS feed unreachable".into());
330        assert_eq!(err.to_string(), "Sensor error: RSS feed unreachable");
331    }
332
333    #[test]
334    fn error_channel_display_message() {
335        let err = Error::Channel("connection closed".into());
336        assert_eq!(err.to_string(), "Channel error: connection closed");
337    }
338
339    #[test]
340    fn error_telegram_display_message() {
341        let err = Error::Telegram("bot token invalid".into());
342        assert_eq!(err.to_string(), "Telegram error: bot token invalid");
343    }
344
345    #[test]
346    fn error_run_timeout_display_message() {
347        let err = Error::RunTimeout(Duration::from_secs(30));
348        assert_eq!(err.to_string(), "Run timed out after 30s");
349    }
350
351    #[test]
352    fn run_timeout_with_partial_usage() {
353        let usage = TokenUsage {
354            input_tokens: 200,
355            output_tokens: 100,
356            ..Default::default()
357        };
358        let err = Error::RunTimeout(Duration::from_secs(60)).with_partial_usage(usage);
359        assert_eq!(err.to_string(), "Run timed out after 60s");
360        let partial = err.partial_usage();
361        assert_eq!(partial.input_tokens, 200);
362        assert_eq!(partial.output_tokens, 100);
363    }
364
365    #[test]
366    fn with_partial_usage_wraps_error() {
367        let usage = TokenUsage {
368            input_tokens: 100,
369            output_tokens: 50,
370            ..Default::default()
371        };
372        let err = Error::MaxTurnsExceeded(5).with_partial_usage(usage);
373        assert_eq!(err.to_string(), "Max turns (5) exceeded");
374        let partial = err.partial_usage();
375        assert_eq!(partial.input_tokens, 100);
376        assert_eq!(partial.output_tokens, 50);
377    }
378
379    #[test]
380    fn with_partial_usage_unwraps_existing() {
381        let inner_usage = TokenUsage {
382            input_tokens: 50,
383            output_tokens: 25,
384            ..Default::default()
385        };
386        let outer_usage = TokenUsage {
387            input_tokens: 100,
388            output_tokens: 50,
389            ..Default::default()
390        };
391        // First wrap
392        let err = Error::MaxTurnsExceeded(5).with_partial_usage(inner_usage);
393        // Second wrap should unwrap the first, not nest
394        let err = err.with_partial_usage(outer_usage);
395
396        // Should be exactly one layer of WithPartialUsage
397        match &err {
398            Error::WithPartialUsage { source, usage } => {
399                assert!(
400                    matches!(**source, Error::MaxTurnsExceeded(5)),
401                    "inner error should be MaxTurnsExceeded, got: {source}"
402                );
403                assert_eq!(usage.input_tokens, 100);
404                assert_eq!(usage.output_tokens, 50);
405            }
406            other => panic!("expected WithPartialUsage, got: {other}"),
407        }
408    }
409
410    #[test]
411    fn error_budget_exceeded_display_message() {
412        let err = Error::BudgetExceeded {
413            used: 150000,
414            limit: 100000,
415        };
416        assert_eq!(
417            err.to_string(),
418            "Token budget exceeded: used 150000, limit 100000"
419        );
420    }
421
422    #[test]
423    fn budget_exceeded_with_partial_usage() {
424        let usage = TokenUsage {
425            input_tokens: 100000,
426            output_tokens: 50000,
427            ..Default::default()
428        };
429        let err = Error::BudgetExceeded {
430            used: 150000,
431            limit: 100000,
432        }
433        .with_partial_usage(usage);
434        assert_eq!(
435            err.to_string(),
436            "Token budget exceeded: used 150000, limit 100000"
437        );
438        let partial = err.partial_usage();
439        assert_eq!(partial.input_tokens, 100000);
440        assert_eq!(partial.output_tokens, 50000);
441    }
442
443    #[test]
444    fn partial_usage_returns_default_for_plain_errors() {
445        let err = Error::Truncated;
446        let partial = err.partial_usage();
447        assert_eq!(partial, TokenUsage::default());
448    }
449}