feat(channel): stream LLM responses to Telegram via draft message edits

Wire the existing provider-layer streaming infrastructure through the
channel trait and agent loop so Telegram users see tokens arrive
progressively via editMessageText, instead of waiting for the full
response.

Changes:
- Add StreamMode enum (off/partial/block) and draft_update_interval_ms
  to TelegramConfig (backward-compatible defaults: off, 1000ms)
- Add supports_draft_updates/send_draft/update_draft/finalize_draft to
  Channel trait with no-op defaults (zero impact on existing channels)
- Implement draft methods on TelegramChannel using sendMessage +
  editMessageText with rate limiting and Markdown fallback
- Add on_delta mpsc::Sender<String> parameter to run_tool_call_loop
  (None preserves existing behavior)
- Wire streaming in process_channel_message: when channel supports
  drafts, send initial draft, spawn updater task, finalize on completion

Edge cases handled:
- 4096-char limit: finalize draft and fall back to chunked send
- Broken Markdown: use no parse_mode during streaming, apply on finalize
- Edit failures: fall back to sending complete response as new message
- Rate limiting: configurable draft_update_interval_ms (default 1s)
This commit is contained in:
Xiangjun Ma 2026-02-17 23:46:32 -08:00 committed by Chummy
parent a0b277b21e
commit 118cd53922
12 changed files with 410 additions and 43 deletions

View file

@ -337,8 +337,7 @@ impl SqliteMemory {
category: Option<&str>,
session_id: Option<&str>,
) -> anyhow::Result<Vec<(String, f32)>> {
let mut sql =
"SELECT id, embedding FROM memories WHERE embedding IS NOT NULL".to_string();
let mut sql = "SELECT id, embedding FROM memories WHERE embedding IS NOT NULL".to_string();
let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
let mut idx = 1;
@ -500,13 +499,11 @@ impl Memory for SqliteMemory {
let session_ref = session_id.as_deref();
// FTS5 BM25 keyword search
let keyword_results =
Self::fts5_search(&conn, &query, limit * 2).unwrap_or_default();
let keyword_results = Self::fts5_search(&conn, &query, limit * 2).unwrap_or_default();
// Vector similarity search (if embeddings available)
let vector_results = if let Some(ref qe) = query_embedding {
Self::vector_search(&conn, qe, limit * 2, None, session_ref)
.unwrap_or_default()
Self::vector_search(&conn, qe, limit * 2, None, session_ref).unwrap_or_default()
} else {
Vec::new()
};
@ -604,11 +601,7 @@ impl Memory for SqliteMemory {
.iter()
.enumerate()
.map(|(i, _)| {
format!(
"(content LIKE ?{} OR key LIKE ?{})",
i * 2 + 1,
i * 2 + 2
)
format!("(content LIKE ?{} OR key LIKE ?{})", i * 2 + 1, i * 2 + 2)
})
.collect();
let where_clause = conditions.join(" OR ");