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}