heartbit-core 2026.507.3

The Rust agentic framework — agents, tools, LLM providers, memory, evaluation.
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
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
//! Error type for all heartbit-core fallible operations.

use std::time::Duration;

use crate::types::TokenUsage;
use thiserror::Error;

/// Top-level error type for the heartbit-core crate.
///
/// All fallible public APIs return `Result<T, Error>`. Callers should match on
/// specific variants rather than converting to strings so that retry logic and
/// error reporting remain precise.
///
/// ## Retryable variants
///
/// The following variants indicate transient conditions that callers *may* retry:
/// - [`Error::Http`] — network-level failures (connection reset, timeout, …)
/// - [`Error::Api`] with `status >= 500` or `status == 429`
/// - [`Error::TenantOverloaded`] — back off and retry when capacity is available
/// - [`Error::CircuitOpen`] — retry after the `until` instant
///
/// ## Token accounting
///
/// [`Error::WithPartialUsage`] wraps any other variant and carries the token
/// usage accumulated before the failure. Inspect it with [`Error::partial_usage`]
/// to charge tokens even on error.
#[derive(Error, Debug)]
pub enum Error {
    /// An HTTP-level error from the `reqwest` client (network failure, TLS error, etc.).
    ///
    /// Potentially retryable depending on the underlying cause.
    #[error("HTTP request failed: {0}")]
    Http(#[from] reqwest::Error),

    /// JSON serialization or deserialization failed.
    ///
    /// Indicates a protocol mismatch or a malformed API response. Not retryable.
    #[error("JSON serialization/deserialization failed: {0}")]
    Json(#[from] serde_json::Error),

    /// The LLM API returned a non-2xx HTTP status code.
    ///
    /// `status == 429` is rate-limited (retryable). `status >= 500` is a
    /// server error (retryable). `status == 400` / `401` / `403` are not
    /// retryable without changing the request.
    #[error("API error ({status}): {message}")]
    Api {
        /// HTTP status code returned by the API.
        status: u16,
        /// Human-readable error message from the response body.
        message: String,
    },

    /// A general agent-level error not covered by a more specific variant.
    ///
    /// Produced by tool execution failures, orchestrator logic errors, and
    /// other agent-layer problems.
    #[error("Agent error: {0}")]
    Agent(String),

    /// Authentication or authorization failure.
    ///
    /// Typically indicates a missing or invalid API key. Not retryable without
    /// supplying valid credentials.
    #[error("Authentication error: {0}")]
    Auth(String),

    /// The agent loop reached its configured maximum turn count without finishing.
    ///
    /// Not retryable — callers should increase `max_turns` or redesign the task.
    #[error("Max turns ({0}) exceeded")]
    MaxTurnsExceeded(usize),

    /// The LLM response was cut off because `max_tokens` was reached.
    ///
    /// The agent loop surfaces this as an error when truncation is fatal. Callers
    /// can increase `max_tokens` or compress context and retry.
    #[error("Response truncated (max_tokens reached)")]
    Truncated,

    /// The agent run exceeded the configured wall-clock timeout.
    ///
    /// Potentially retryable with a longer timeout or a simpler task.
    #[error("Run timed out after {0:?}")]
    RunTimeout(Duration),

    /// An error originating from the Model Context Protocol (MCP) client or server.
    ///
    /// Covers handshake failures, protocol violations, and tool call errors
    /// returned by remote MCP servers.
    #[error("MCP error: {0}")]
    Mcp(String),

    /// An error from the Agent-to-Agent (A2A) protocol layer.
    ///
    /// Returned when communicating with remote A2A agents fails.
    #[error("A2A error: {0}")]
    A2a(String),

    /// An error in configuration parsing or validation.
    ///
    /// Produced by `HeartbitConfig` deserialization and by builder `build()` calls
    /// that detect invalid combinations of options.
    #[error("Configuration error: {0}")]
    Config(String),

    /// A persistence-layer error (e.g., PostgreSQL task-store failure).
    ///
    /// Potentially retryable on transient connection errors.
    #[error("Store error: {0}")]
    Store(String),

    /// An error in the agent memory subsystem (recall, store, prune, etc.).
    #[error("Memory error: {0}")]
    Memory(String),

    /// An error in the knowledge-base subsystem (indexing, chunking, search).
    #[error("Knowledge error: {0}")]
    Knowledge(String),

    /// A guardrail denied or errored during a request.
    ///
    /// Produced when a [`crate::Guardrail`] hook returns `Deny` or when the
    /// guardrail itself fails. The message contains the denial reason.
    #[error("Guardrail error: {0}")]
    Guardrail(String),

    /// An error in the daemon execution path (Kafka consumer, dispatcher, etc.).
    #[error("Daemon error: {0}")]
    Daemon(String),

    /// An error in the sensor pipeline (RSS, webhook, schedule triggers).
    #[error("Sensor error: {0}")]
    Sensor(String),

    /// The agent exceeded its token budget before completing.
    ///
    /// `used` is the total tokens consumed; `limit` is the configured cap.
    /// Not retryable without either increasing the budget or reducing the task.
    #[error("Token budget exceeded: used {used}, limit {limit}")]
    BudgetExceeded {
        /// Total tokens consumed before the budget was exhausted.
        used: u64,
        /// The configured token budget that was exceeded.
        limit: u64,
    },

    /// An error in the WebSocket/session channel layer.
    #[error("Channel error: {0}")]
    Channel(String),

    /// An error originating from the Telegram bot adapter.
    #[error("Telegram error: {0}")]
    Telegram(String),

    /// A kill switch was activated, terminating the agent run immediately.
    ///
    /// Produced by the kill-switch guardrail when a prohibited pattern is detected.
    #[error("Kill switch activated: {0}")]
    KillSwitch(String),

    /// The agent attempted a filesystem operation that violates the sandbox policy.
    ///
    /// Produced by `CorePathPolicy::check_path` or the Landlock sandbox.
    #[error("Sandbox violation: {0}")]
    Sandbox(String),

    /// The tenant has reached its maximum concurrent-request capacity.
    ///
    /// Retryable: callers should back off and retry after a delay.
    #[error("tenant {tenant_id} overloaded: in_flight={in_flight}, cap={cap}")]
    TenantOverloaded {
        /// The tenant identifier that is overloaded.
        tenant_id: String,
        /// Number of requests currently in flight for this tenant.
        in_flight: usize,
        /// Maximum allowed concurrent requests for this tenant.
        cap: usize,
    },

    /// The LLM provider's circuit breaker is open; requests are being shed.
    ///
    /// Retryable: callers should retry after the `until` instant has passed.
    #[error("circuit breaker open: retry after {until:?} (prev open duration: {prev_duration:?})")]
    CircuitOpen {
        /// The instant after which requests should be retried.
        until: std::time::Instant,
        /// How long the circuit was open in the previous open window.
        prev_duration: std::time::Duration,
    },

    /// Wraps another error with partial token usage accumulated before failure.
    ///
    /// Used by `AgentRunner::execute` to surface tokens consumed before an error.
    /// Inspect partial usage with [`Error::partial_usage`]. Re-wrapping an existing
    /// `WithPartialUsage` replaces the usage rather than nesting.
    #[error("{source}")]
    WithPartialUsage {
        /// The underlying error that caused the agent run to abort.
        #[source]
        source: Box<Error>,
        /// Token usage accumulated before the error occurred.
        usage: TokenUsage,
    },
}

impl Error {
    /// Wrap this error with partial token usage data.
    ///
    /// If `self` is already `WithPartialUsage`, the inner error is unwrapped
    /// first to prevent nesting. The new `usage` replaces the old one.
    pub fn with_partial_usage(self, usage: TokenUsage) -> Self {
        let inner = match self {
            Error::WithPartialUsage { source, .. } => *source,
            other => other,
        };
        Error::WithPartialUsage {
            source: Box::new(inner),
            usage,
        }
    }

    /// Wrap this error with the sum of `prior` usage and the error's own partial usage.
    ///
    /// Shorthand for `e.with_partial_usage(prior + e.partial_usage())`.
    pub fn accumulate_usage(self, prior: TokenUsage) -> Self {
        let mut usage = prior;
        usage += self.partial_usage();
        self.with_partial_usage(usage)
    }

    /// Extract partial token usage from this error.
    /// Returns `TokenUsage::default()` for errors that don't carry usage data.
    pub fn partial_usage(&self) -> TokenUsage {
        match self {
            Error::WithPartialUsage { usage, .. } => *usage,
            _ => TokenUsage::default(),
        }
    }
}

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

    #[test]
    fn error_display_messages() {
        let err = Error::Api {
            status: 429,
            message: "rate limited".into(),
        };
        assert_eq!(err.to_string(), "API error (429): rate limited");

        let err = Error::MaxTurnsExceeded(10);
        assert_eq!(err.to_string(), "Max turns (10) exceeded");

        let err = Error::Truncated;
        assert_eq!(err.to_string(), "Response truncated (max_tokens reached)");
    }

    #[test]
    fn error_auth_display_message() {
        let err = Error::Auth("invalid token".into());
        assert_eq!(err.to_string(), "Authentication error: invalid token");
    }

    #[test]
    fn error_mcp_display_message() {
        let err = Error::Mcp("connection refused".into());
        assert_eq!(err.to_string(), "MCP error: connection refused");
    }

    #[test]
    fn error_a2a_display_message() {
        let err = Error::A2a("agent not found".into());
        assert_eq!(err.to_string(), "A2A error: agent not found");
    }

    #[test]
    fn error_store_display_message() {
        let err = Error::Store("connection refused".into());
        assert_eq!(err.to_string(), "Store error: connection refused");
    }

    #[test]
    fn error_memory_display_message() {
        let err = Error::Memory("not found".into());
        assert_eq!(err.to_string(), "Memory error: not found");
    }

    #[test]
    fn error_knowledge_display_message() {
        let err = Error::Knowledge("file not found".into());
        assert_eq!(err.to_string(), "Knowledge error: file not found");
    }

    #[test]
    fn error_guardrail_display_message() {
        let err = Error::Guardrail("PII detected in output".into());
        assert_eq!(err.to_string(), "Guardrail error: PII detected in output");
    }

    #[test]
    fn error_daemon_display_message() {
        let err = Error::Daemon("broker connection refused".into());
        assert_eq!(err.to_string(), "Daemon error: broker connection refused");
    }

    #[test]
    fn error_sensor_display_message() {
        let err = Error::Sensor("RSS feed unreachable".into());
        assert_eq!(err.to_string(), "Sensor error: RSS feed unreachable");
    }

    #[test]
    fn error_channel_display_message() {
        let err = Error::Channel("connection closed".into());
        assert_eq!(err.to_string(), "Channel error: connection closed");
    }

    #[test]
    fn error_telegram_display_message() {
        let err = Error::Telegram("bot token invalid".into());
        assert_eq!(err.to_string(), "Telegram error: bot token invalid");
    }

    #[test]
    fn error_run_timeout_display_message() {
        let err = Error::RunTimeout(Duration::from_secs(30));
        assert_eq!(err.to_string(), "Run timed out after 30s");
    }

    #[test]
    fn run_timeout_with_partial_usage() {
        let usage = TokenUsage {
            input_tokens: 200,
            output_tokens: 100,
            ..Default::default()
        };
        let err = Error::RunTimeout(Duration::from_secs(60)).with_partial_usage(usage);
        assert_eq!(err.to_string(), "Run timed out after 60s");
        let partial = err.partial_usage();
        assert_eq!(partial.input_tokens, 200);
        assert_eq!(partial.output_tokens, 100);
    }

    #[test]
    fn with_partial_usage_wraps_error() {
        let usage = TokenUsage {
            input_tokens: 100,
            output_tokens: 50,
            ..Default::default()
        };
        let err = Error::MaxTurnsExceeded(5).with_partial_usage(usage);
        assert_eq!(err.to_string(), "Max turns (5) exceeded");
        let partial = err.partial_usage();
        assert_eq!(partial.input_tokens, 100);
        assert_eq!(partial.output_tokens, 50);
    }

    #[test]
    fn with_partial_usage_unwraps_existing() {
        let inner_usage = TokenUsage {
            input_tokens: 50,
            output_tokens: 25,
            ..Default::default()
        };
        let outer_usage = TokenUsage {
            input_tokens: 100,
            output_tokens: 50,
            ..Default::default()
        };
        // First wrap
        let err = Error::MaxTurnsExceeded(5).with_partial_usage(inner_usage);
        // Second wrap should unwrap the first, not nest
        let err = err.with_partial_usage(outer_usage);

        // Should be exactly one layer of WithPartialUsage
        match &err {
            Error::WithPartialUsage { source, usage } => {
                assert!(
                    matches!(**source, Error::MaxTurnsExceeded(5)),
                    "inner error should be MaxTurnsExceeded, got: {source}"
                );
                assert_eq!(usage.input_tokens, 100);
                assert_eq!(usage.output_tokens, 50);
            }
            other => panic!("expected WithPartialUsage, got: {other}"),
        }
    }

    #[test]
    fn error_budget_exceeded_display_message() {
        let err = Error::BudgetExceeded {
            used: 150000,
            limit: 100000,
        };
        assert_eq!(
            err.to_string(),
            "Token budget exceeded: used 150000, limit 100000"
        );
    }

    #[test]
    fn budget_exceeded_with_partial_usage() {
        let usage = TokenUsage {
            input_tokens: 100000,
            output_tokens: 50000,
            ..Default::default()
        };
        let err = Error::BudgetExceeded {
            used: 150000,
            limit: 100000,
        }
        .with_partial_usage(usage);
        assert_eq!(
            err.to_string(),
            "Token budget exceeded: used 150000, limit 100000"
        );
        let partial = err.partial_usage();
        assert_eq!(partial.input_tokens, 100000);
        assert_eq!(partial.output_tokens, 50000);
    }

    #[test]
    fn partial_usage_returns_default_for_plain_errors() {
        let err = Error::Truncated;
        let partial = err.partial_usage();
        assert_eq!(partial, TokenUsage::default());
    }
}