Skip to main content

heartbit_core/channel/
mod.rs

1//! Channel base traits and in-process implementations.
2//!
3//! Platform-specific adapters (Telegram, Discord, Slack) and the
4//! Postgres-backed session store live in the heartbit umbrella crate.
5
6pub mod bridge;
7pub mod session;
8pub mod types;
9
10use std::future::Future;
11use std::pin::Pin;
12use std::sync::Arc;
13
14use crate::agent::events::OnEvent;
15use crate::error::Error;
16use crate::llm::{OnApproval, OnText};
17use crate::memory::Memory;
18use crate::tool::builtins::OnQuestion;
19
20/// A media attachment from a messaging channel (photo, voice, document).
21pub struct MediaAttachment {
22    /// MIME type or platform-specific media kind (`image/jpeg`, `voice`, etc.).
23    pub media_type: String,
24    /// Raw attachment bytes.
25    pub data: Vec<u8>,
26    /// Optional caption supplied with the attachment.
27    pub caption: Option<String>,
28}
29
30/// Trait for channel-specific bridges that produce agent callbacks.
31///
32/// Each messaging channel (Telegram, Discord, etc.) implements this trait
33/// so the same `RunTask` closure can drive any channel without duplication.
34pub trait ChannelBridge: Send + Sync {
35    /// Produce the `OnText` callback that forwards streaming text to the client.
36    fn make_on_text(self: Arc<Self>) -> Arc<OnText>;
37    /// Produce the `OnEvent` callback that forwards `AgentEvent` records to the client.
38    fn make_on_event(self: Arc<Self>) -> Arc<OnEvent>;
39    /// Produce the `OnApproval` callback for human-in-the-loop tool gating.
40    fn make_on_approval(self: Arc<Self>) -> Arc<OnApproval>;
41    /// Produce the `OnQuestion` callback for structured agent-to-user questions.
42    fn make_on_question(self: Arc<Self>) -> Arc<OnQuestion>;
43}
44
45/// Input for the `RunTask` callback.
46pub struct RunTaskInput {
47    /// User-typed task text.
48    pub task_text: String,
49    /// Channel bridge providing the callback set for this run.
50    pub bridge: Arc<dyn ChannelBridge>,
51    /// Pre-existing shared memory store so sub-agent memory tools persist
52    /// across tasks. Passed as the raw (un-namespaced) store.
53    pub memory: Option<Arc<dyn Memory>>,
54    /// User-specific namespace prefix (e.g. `"tg:12345"`). Passed as `story_id`
55    /// to `build_orchestrator_from_config` for per-user memory isolation.
56    pub user_namespace: Option<String>,
57    /// Media attachments (photos, documents). Empty for text-only messages.
58    pub attachments: Vec<MediaAttachment>,
59}
60
61/// Callback type for running an agent task with bridge callbacks.
62///
63/// The CLI crate provides this closure to wire `build_orchestrator_from_config`
64/// with the channel bridge callbacks. Returns the agent's final text output.
65pub type RunTask = dyn Fn(RunTaskInput) -> Pin<Box<dyn Future<Output = Result<String, Error>> + Send>>
66    + Send
67    + Sync;
68
69/// Callback type for memory consolidation on idle sessions.
70pub type ConsolidateSession =
71    dyn Fn(i64) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send>> + Send + Sync;
72
73/// Split a message into chunks that fit a platform's message-length limit.
74///
75/// Tries to split at newlines for readability; falls back to the largest
76/// UTF-8 char boundary that fits. When `max_len` is smaller than a single
77/// character, that character is emitted whole (a codepoint cannot be split).
78/// Shared by Discord, Slack, and other channel adapters.
79pub fn chunk_message(text: &str, max_len: usize) -> Vec<&str> {
80    if text.len() <= max_len {
81        return vec![text];
82    }
83    let mut chunks = Vec::new();
84    let mut remaining = text;
85    while !remaining.is_empty() {
86        if remaining.len() <= max_len {
87            chunks.push(remaining);
88            break;
89        }
90        // Largest char-boundary window that fits the limit — computed BEFORE
91        // any slicing, so a multibyte character at `max_len` cannot panic.
92        let window_end = crate::tool::builtins::floor_char_boundary(remaining, max_len);
93        // Prefer splitting at the last newline inside the window.
94        let split_at = match remaining[..window_end].rfind('\n') {
95            Some(nl) if nl > 0 => nl,
96            // No usable newline: take the whole window.
97            _ if window_end > 0 => window_end,
98            // Degenerate: max_len is smaller than the first character. Emit
99            // it whole so the loop always advances.
100            _ => remaining
101                .chars()
102                .next()
103                .map(char::len_utf8)
104                .unwrap_or(remaining.len()),
105        };
106        chunks.push(&remaining[..split_at]);
107        remaining = &remaining[split_at..];
108        // Skip leading newline after split
109        if remaining.starts_with('\n') {
110            remaining = &remaining[1..];
111        }
112    }
113    chunks
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn channel_bridge_is_object_safe() {
122        // Compile-time check: ChannelBridge can be used as a trait object.
123        fn _assert(_: &Arc<dyn ChannelBridge>) {}
124    }
125
126    #[test]
127    fn run_task_input_accepts_dyn_bridge() {
128        // Compile-time check: RunTaskInput.bridge is Arc<dyn ChannelBridge>.
129        fn _assert(bridge: Arc<dyn ChannelBridge>) {
130            let _input = RunTaskInput {
131                task_text: String::new(),
132                bridge,
133                memory: None,
134                user_namespace: None,
135                attachments: Vec::new(),
136            };
137        }
138    }
139
140    #[test]
141    fn chunk_message_short_text_passthrough() {
142        assert_eq!(chunk_message("hello", 10), vec!["hello"]);
143    }
144
145    #[test]
146    fn chunk_message_prefers_newline_split() {
147        let chunks = chunk_message("aaa\nbbb", 5);
148        assert_eq!(chunks, vec!["aaa", "bbb"]);
149    }
150
151    // Panic-safety: byte index max_len falling INSIDE a multibyte character
152    // must not panic — the old code sliced `remaining[..max_len]` before the
153    // char-boundary fallback ever ran. An LLM reply in French/emoji over the
154    // platform limit is the live trigger (Discord/Slack bridges).
155    #[test]
156    fn chunk_message_multibyte_at_boundary_does_not_panic() {
157        // "é" is 2 bytes; an odd max_len lands mid-codepoint.
158        let text = "é".repeat(60); // 120 bytes
159        let chunks = chunk_message(&text, 5);
160        // No newlines in the input, so the chunks must reassemble losslessly.
161        assert_eq!(chunks.concat(), text);
162        for c in &chunks {
163            assert!(c.len() <= 5, "chunk over limit: {} bytes", c.len());
164        }
165    }
166
167    #[test]
168    fn chunk_message_emoji_at_boundary_does_not_panic() {
169        // 4-byte emoji; max_len = 10 lands mid-codepoint (10 % 4 != 0).
170        let text = "🦀".repeat(30); // 120 bytes
171        let chunks = chunk_message(&text, 10);
172        assert_eq!(chunks.concat(), text);
173        for c in &chunks {
174            assert!(c.len() <= 10, "chunk over limit: {} bytes", c.len());
175        }
176    }
177
178    // The `split_at == 0` fallback used to re-assign a raw `max_len` index,
179    // reintroducing the non-boundary slice even after a safe one was computed:
180    // a window whose ONLY newline is at position 0 takes that path.
181    #[test]
182    fn chunk_message_leading_newline_window_does_not_panic() {
183        let mut text = String::from("\n");
184        text.push_str(&"é".repeat(30)); // newline + 60 bytes of 2-byte chars
185        let chunks = chunk_message(&text, 5);
186        assert!(!chunks.is_empty());
187        for c in &chunks {
188            assert!(c.len() <= 5, "chunk over limit: {} bytes", c.len());
189        }
190    }
191
192    // Degenerate limit: max_len smaller than the first character's width. A
193    // codepoint cannot be split, so the chunk carries the whole char (the only
194    // non-panicking option) and the loop must still terminate.
195    #[test]
196    fn chunk_message_max_len_smaller_than_char_still_terminates() {
197        let chunks = chunk_message("日本語", 1);
198        assert_eq!(chunks, vec!["日", "本", "語"]);
199    }
200}