Skip to content

Commit 16bed85

Browse files
author
brett_0122
committed
feat(dispatch): turn-boundary batching dispatcher v2 per ADR v0.3
1 parent 250ff93 commit 16bed85

8 files changed

Lines changed: 1116 additions & 122 deletions

File tree

src/adapter.rs

Lines changed: 46 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,10 @@ pub struct SenderContext {
8787
#[serde(skip_serializing_if = "Option::is_none")]
8888
pub thread_id: Option<String>,
8989
pub is_bot: bool,
90+
/// Platform message creation time (ISO 8601 UTC).
91+
/// Discord/Slack: platform timestamp. Gateway: broker receive time (best-effort).
92+
/// Additive field — schema stays openab.sender.v1.
93+
pub timestamp: String,
9094
}
9195

9296
// --- ChatAdapter trait ---
@@ -160,6 +164,32 @@ impl AdapterRouter {
160164
&self.pool
161165
}
162166

167+
/// Access the reactions config (used by dispatch.rs).
168+
pub fn reactions_config(&self) -> &ReactionsConfig {
169+
&self.reactions_config
170+
}
171+
172+
/// Pack one arrival event into ContentBlocks using the uniform per-arrival template:
173+
/// Text { "<sender_context>\n{json}\n</sender_context>\n\n{prompt}" }
174+
/// [extra_blocks in arrival order]
175+
///
176+
/// This is the single packing code path for both per-message and batched dispatch
177+
/// (ADR §3.5). For a batch of N messages, call this N times and concatenate.
178+
pub fn pack_arrival_event(
179+
sender_json: &str,
180+
prompt: &str,
181+
extra_blocks: Vec<ContentBlock>,
182+
) -> Vec<ContentBlock> {
183+
let header = format!(
184+
"<sender_context>\n{}\n</sender_context>\n\n{}",
185+
sender_json, prompt
186+
);
187+
let mut blocks = Vec::with_capacity(1 + extra_blocks.len());
188+
blocks.push(ContentBlock::Text { text: header });
189+
blocks.extend(extra_blocks);
190+
blocks
191+
}
192+
163193
/// Handle an incoming user message. The adapter is responsible for
164194
/// filtering, resolving the thread, and building the SenderContext.
165195
/// This method handles sender context injection, session management, and streaming.
@@ -176,28 +206,7 @@ impl AdapterRouter {
176206
) -> Result<()> {
177207
tracing::debug!(platform = adapter.platform(), "processing message");
178208

179-
// Build content blocks: sender context + prompt text, then extra (images, transcripts)
180-
let prompt_with_sender = format!(
181-
"<sender_context>\n{}\n</sender_context>\n\n{}",
182-
sender_json, prompt
183-
);
184-
185-
let mut content_blocks = Vec::with_capacity(1 + extra_blocks.len());
186-
// Prepend any transcript blocks (they go before the text block)
187-
for block in &extra_blocks {
188-
if matches!(block, ContentBlock::Text { .. }) {
189-
content_blocks.push(block.clone());
190-
}
191-
}
192-
content_blocks.push(ContentBlock::Text {
193-
text: prompt_with_sender,
194-
});
195-
// Append non-text blocks (images)
196-
for block in extra_blocks {
197-
if !matches!(block, ContentBlock::Text { .. }) {
198-
content_blocks.push(block);
199-
}
200-
}
209+
let content_blocks = Self::pack_arrival_event(sender_json, prompt, extra_blocks);
201210

202211
let thread_key = format!(
203212
"{}:{}",
@@ -272,6 +281,21 @@ impl AdapterRouter {
272281
thread_channel: &ChannelRef,
273282
reactions: Arc<StatusReactionController>,
274283
other_bot_present: bool,
284+
) -> Result<()> {
285+
self.stream_prompt_blocks(adapter, thread_key, content_blocks, thread_channel, reactions, other_bot_present).await
286+
}
287+
288+
/// Drive one ACP turn with the given pre-packed ContentBlocks.
289+
/// Called by both `handle_message` (per-message mode) and `dispatch::dispatch_batch`
290+
/// (batched mode).
291+
pub async fn stream_prompt_blocks(
292+
&self,
293+
adapter: &Arc<dyn ChatAdapter>,
294+
thread_key: &str,
295+
content_blocks: Vec<ContentBlock>,
296+
thread_channel: &ChannelRef,
297+
reactions: Arc<StatusReactionController>,
298+
other_bot_present: bool,
275299
) -> Result<()> {
276300
let adapter = adapter.clone();
277301
let thread_channel = thread_channel.clone();

src/config.rs

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,29 @@ use serde::Deserialize;
44
use std::collections::HashMap;
55
use std::path::Path;
66

7+
/// Controls how incoming messages are dispatched to ACP turns.
8+
///
9+
/// - `PerMessage` (default): each message becomes its own ACP turn (v0.8.2-beta.1 behaviour).
10+
/// - `Batched`: messages that arrive while a turn is in flight are buffered and merged
11+
/// into one ACP turn at the next turn boundary (see ADR: turn-boundary-batching-adr.md).
12+
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
13+
pub enum MessageProcessingMode {
14+
#[default]
15+
PerMessage,
16+
Batched,
17+
}
18+
19+
impl<'de> Deserialize<'de> for MessageProcessingMode {
20+
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
21+
let s = String::deserialize(deserializer)?;
22+
match s.to_lowercase().replace('-', "_").as_str() {
23+
"per_message" | "per-message" => Ok(Self::PerMessage),
24+
"batched" => Ok(Self::Batched),
25+
other => Err(serde::de::Error::unknown_variant(other, &["per-message", "batched"])),
26+
}
27+
}
28+
}
29+
730
/// Controls whether the bot processes messages from other Discord bots.
831
///
932
/// Inspired by Hermes Agent's `DISCORD_ALLOW_BOTS` 3-value design:
@@ -120,9 +143,20 @@ pub struct DiscordConfig {
120143
/// Default: false (opt-in). `allowed_users` still applies in DMs.
121144
#[serde(default)]
122145
pub allow_dm: bool,
146+
/// Message dispatch mode. Default: per-message (v0.8.2-beta.1 behaviour).
147+
#[serde(default)]
148+
pub message_processing_mode: MessageProcessingMode,
149+
/// Batched mode only: per-thread channel capacity. Default: 10.
150+
#[serde(default = "default_max_buffered_messages")]
151+
pub max_buffered_messages: usize,
152+
/// Batched mode only: soft token cap for greedy drain. Default: 24000.
153+
#[serde(default = "default_max_batch_tokens")]
154+
pub max_batch_tokens: usize,
123155
}
124156

125157
fn default_max_bot_turns() -> u32 { 20 }
158+
fn default_max_buffered_messages() -> usize { 10 }
159+
fn default_max_batch_tokens() -> usize { 24_000 }
126160

127161
/// Controls whether the bot responds to user messages in threads without @mention.
128162
///
@@ -179,6 +213,15 @@ pub struct SlackConfig {
179213
/// Human message resets the counter. Default: 20.
180214
#[serde(default = "default_max_bot_turns")]
181215
pub max_bot_turns: u32,
216+
/// Message dispatch mode. Default: per-message.
217+
#[serde(default)]
218+
pub message_processing_mode: MessageProcessingMode,
219+
/// Batched mode only: per-thread channel capacity. Default: 10.
220+
#[serde(default = "default_max_buffered_messages")]
221+
pub max_buffered_messages: usize,
222+
/// Batched mode only: soft token cap for greedy drain. Default: 24000.
223+
#[serde(default = "default_max_batch_tokens")]
224+
pub max_batch_tokens: usize,
182225
}
183226

184227
#[derive(Debug, Deserialize)]
@@ -202,6 +245,15 @@ pub struct GatewayConfig {
202245
pub allowed_channels: Vec<String>,
203246
#[serde(default)]
204247
pub allowed_users: Vec<String>,
248+
/// Message dispatch mode. Default: per-message.
249+
#[serde(default)]
250+
pub message_processing_mode: MessageProcessingMode,
251+
/// Batched mode only: per-thread channel capacity. Default: 10.
252+
#[serde(default = "default_max_buffered_messages")]
253+
pub max_buffered_messages: usize,
254+
/// Batched mode only: soft token cap for greedy drain. Default: 24000.
255+
#[serde(default = "default_max_batch_tokens")]
256+
pub max_batch_tokens: usize,
205257
}
206258

207259
fn default_gateway_platform() -> String {
@@ -280,7 +332,7 @@ impl<'de> Deserialize<'de> for ToolDisplay {
280332
}
281333
}
282334

283-
#[derive(Debug, Deserialize)]
335+
#[derive(Debug, Clone, Deserialize)]
284336
pub struct ReactionsConfig {
285337
#[serde(default = "default_true")]
286338
pub enabled: bool,

src/cron.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,7 @@ async fn fire_cronjob(
350350
channel_id: reply_channel.parent_id.as_deref().unwrap_or(&reply_channel.channel_id).to_string(),
351351
thread_id: reply_channel.thread_id.clone().or(Some(reply_channel.channel_id.clone())),
352352
is_bot: true,
353+
timestamp: Utc::now().to_rfc3339(),
353354
};
354355
let sender_json = match serde_json::to_string(&sender) {
355356
Ok(j) => j,

src/discord.rs

Lines changed: 69 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,9 @@ pub struct Handler {
160160
pub bot_turns: tokio::sync::Mutex<BotTurnTracker>,
161161
/// Allow the bot to respond to Discord DMs.
162162
pub allow_dm: bool,
163+
/// Batched-mode dispatcher (None in per-message mode).
164+
pub dispatcher: Option<Arc<crate::dispatch::Dispatcher>>,
165+
pub message_processing_mode: crate::config::MessageProcessingMode,
163166
}
164167

165168
impl Handler {
@@ -527,6 +530,7 @@ impl EventHandler for Handler {
527530
&msg.channel_id.to_string(),
528531
thread_parent_id.as_deref(),
529532
msg.author.bot,
533+
&msg.timestamp.to_rfc3339().unwrap_or_default(),
530534
);
531535

532536
// Build extra content blocks from attachments (audio → STT, text → inline, image → encode)
@@ -622,19 +626,58 @@ impl EventHandler for Handler {
622626
let trigger_msg = discord_msg_ref(&msg);
623627

624628
// Per-thread streaming: check if another bot is present in this thread
625-
let other_bot_present = {
629+
let other_bot_present_flag = {
626630
let cache = self.multibot_threads.lock().await;
627631
cache.contains_key(&msg.channel_id.to_string())
628632
};
629633

630634
let router = self.router.clone();
635+
let mode = self.message_processing_mode;
636+
let dispatcher = self.dispatcher.clone();
637+
631638
tokio::spawn(async move {
632639
let sender_json = serde_json::to_string(&sender).unwrap();
633-
if let Err(e) = router
634-
.handle_message(&adapter, &thread_channel, &sender_json, &prompt, extra_blocks, &trigger_msg, other_bot_present)
635-
.await
636-
{
637-
error!("handle_message error: {e}");
640+
match mode {
641+
crate::config::MessageProcessingMode::PerMessage => {
642+
if let Err(e) = router
643+
.handle_message(
644+
&adapter,
645+
&thread_channel,
646+
&sender_json,
647+
&prompt,
648+
extra_blocks,
649+
&trigger_msg,
650+
other_bot_present_flag,
651+
)
652+
.await
653+
{
654+
error!("handle_message error: {e}");
655+
}
656+
}
657+
crate::config::MessageProcessingMode::Batched => {
658+
if let Some(dispatcher) = dispatcher {
659+
let thread_key = format!("discord:{}", thread_channel.channel_id);
660+
let estimated_tokens =
661+
crate::dispatch::estimate_tokens(&prompt, &extra_blocks);
662+
let buf_msg = crate::dispatch::BufferedMessage {
663+
sender_json,
664+
prompt,
665+
extra_blocks,
666+
trigger_msg,
667+
arrived_at: std::time::Instant::now(),
668+
estimated_tokens,
669+
other_bot_present: other_bot_present_flag,
670+
};
671+
if let Err(e) = dispatcher
672+
.submit(thread_key, thread_channel, adapter, buf_msg)
673+
.await
674+
{
675+
error!("dispatcher submit error: {e}");
676+
}
677+
} else {
678+
error!("batched mode enabled but no dispatcher configured");
679+
}
680+
}
638681
}
639682
});
640683
}
@@ -854,10 +897,25 @@ impl Handler {
854897
cmd: &serenity::model::application::CommandInteraction,
855898
) {
856899
let thread_key = format!("discord:{}", cmd.channel_id.get());
900+
901+
// Drop any messages buffered for this thread before resetting the session.
902+
// /reset semantics include /cancel-all: discard buffered work, then reset.
903+
let dropped = self
904+
.dispatcher
905+
.as_ref()
906+
.map(|d| d.cancel_buffered(&thread_key))
907+
.unwrap_or(0);
908+
857909
let result = self.router.pool().reset_session(&thread_key).await;
858910

859911
let msg = match result {
912+
Ok(()) if dropped > 0 => {
913+
format!("🔄 Session reset. Dropped {dropped} buffered message(s). Start a new conversation!")
914+
}
860915
Ok(()) => "🔄 Session reset. Start a new conversation!".to_string(),
916+
Err(_) if dropped > 0 => {
917+
format!("🔄 Dropped {dropped} buffered message(s). No active session to reset.")
918+
}
861919
Err(_) => "⚠️ No active session to reset. Start a conversation first by @mentioning the bot.".to_string(),
862920
};
863921

@@ -1096,6 +1154,7 @@ fn build_sender_context(
10961154
msg_channel_id: &str,
10971155
thread_parent_id: Option<&str>,
10981156
is_bot: bool,
1157+
timestamp: &str,
10991158
) -> SenderContext {
11001159
SenderContext {
11011160
schema: "openab.sender.v1".into(),
@@ -1106,6 +1165,7 @@ fn build_sender_context(
11061165
channel_id: thread_parent_id.unwrap_or(msg_channel_id).to_string(),
11071166
thread_id: thread_parent_id.map(|_| msg_channel_id.to_string()),
11081167
is_bot,
1168+
timestamp: timestamp.to_string(),
11091169
}
11101170
}
11111171

@@ -1430,7 +1490,7 @@ mod tests {
14301490
/// In-thread message: channel_id = parent, thread_id = thread channel ID.
14311491
#[test]
14321492
fn build_sender_context_in_thread() {
1433-
let ctx = build_sender_context("user1", "alice", "Alice", "thread_ch", Some("parent_ch"), false);
1493+
let ctx = build_sender_context("user1", "alice", "Alice", "thread_ch", Some("parent_ch"), false, "2026-05-01T00:00:00Z");
14341494
assert_eq!(ctx.channel_id, "parent_ch");
14351495
assert_eq!(ctx.thread_id, Some("thread_ch".to_string()));
14361496
assert_eq!(ctx.channel, "discord");
@@ -1441,15 +1501,15 @@ mod tests {
14411501
/// Non-thread message: channel_id = message channel, thread_id = None.
14421502
#[test]
14431503
fn build_sender_context_not_in_thread() {
1444-
let ctx = build_sender_context("user1", "alice", "Alice", "main_ch", None, false);
1504+
let ctx = build_sender_context("user1", "alice", "Alice", "main_ch", None, false, "2026-05-01T00:00:00Z");
14451505
assert_eq!(ctx.channel_id, "main_ch");
14461506
assert_eq!(ctx.thread_id, None);
14471507
}
14481508

14491509
/// Bot sender: is_bot flag propagated correctly.
14501510
#[test]
14511511
fn build_sender_context_bot_sender() {
1452-
let ctx = build_sender_context("bot1", "mybot", "MyBot", "ch", Some("parent"), true);
1512+
let ctx = build_sender_context("bot1", "mybot", "MyBot", "ch", Some("parent"), true, "2026-05-01T00:00:00Z");
14531513
assert!(ctx.is_bot);
14541514
assert_eq!(ctx.channel_id, "parent");
14551515
assert_eq!(ctx.thread_id, Some("ch".to_string()));

0 commit comments

Comments
 (0)