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}