Skip to content

Commit c06e0e6

Browse files
authored
feat: agent-controlled reply-to via [[reply_to:message_id]] directive (#777)
* feat: agent-controlled reply-to via [[reply_to:message_id]] directive Problem: Agents currently cannot reply to a specific message in a thread. All output is sent as plain messages, losing conversational context in busy multi-bot threads. Solution: Two-layer change enabling agents to specify reply targets: 1. Input: SenderContext now includes message_id, so agents know the ID of each incoming message. 2. Output: Agents can prefix their response with directives: [[reply_to:1502606076451885136]] Actual reply content... OAB parses consecutive [[key:value]] lines as a header block, strips them from content, and uses them for platform-specific message delivery (Discord: message_reference). Design decisions evaluated: - Option A (chosen): Inline directives in output text. Minimal change, no ACP protocol modification, forward-compatible (unknown keys ignored). - Option B (deferred): ACP metadata field. Cleaner but requires protocol change across all backends. Appropriate when more directives are needed (ephemeral, components, attachments). Directive format: - Consecutive [[key:value]] lines at output start = header block - First non-[[...]] line = content begins - Unknown keys silently ignored (forward compatible) - reply_to value must be numeric snowflake (validated) - Extensible: future directives like [[ephemeral:true]] can be added Implementation: - adapter.rs: parse_output_directives() + OutputDirectives struct - adapter.rs: ChatAdapter::send_message_with_reply() (default: fallback) - discord.rs: CreateMessage::reference_message() for reply-to - discord.rs: build_sender_context() includes msg.id - slack.rs, cron.rs, gateway.rs: message_id field added to SenderContext * fix: address review findings on reply-to directive F1 (streaming path): reply_to only works in send-once mode. This is acceptable because streaming is disabled in multi-bot threads (where reply-to matters most). Added explanatory comment. F2 (CRLF offset): Fixed parse_output_directives to handle both \n and \r\n line endings correctly instead of assuming +1. F3 (API error fallback): send_message_with_reply now catches Discord API errors (unknown message, cross-channel reference) and falls back to plain send_message instead of propagating the error. * fix: reply_to directive works in streaming path too When reply_to directive is present and streaming mode created a placeholder, the placeholder is blanked (zero-width space) and the real content is sent as a new reply message. This ensures reply_to has consistent semantics regardless of streaming mode. Behavior: - Streaming + no reply_to: normal edit-in-place (unchanged) - Streaming + reply_to: blank placeholder, send as reply - Send-once + reply_to: send as reply (unchanged) * docs: add output directives documentation Covers: - Directive format spec ([[key:value]] header block) - reply_to directive usage and behavior - SenderContext.message_id for getting message IDs - Multi-agent use case example - Comparison with OpenClaw/Hermes Agent - Future directives roadmap * docs: add agent-controlled reply-to to README features * test: add unit tests for parse_output_directives 8 tests covering: - Normal reply_to parsing - No directives (plain content) - Multiple directives (unknown keys ignored) - Invalid reply_to (non-numeric) rejected - Empty reply_to rejected - CRLF line endings handled correctly - Directive-only output (no content) - Non-directive first line stops parsing * fix: simplify streaming + reply_to path (remove redundant edits) Per review: the three sequential edits were wasteful and caused brief content duplication. Simplified to: 1. Single edit to zero-width space (hide placeholder) 2. Send real content as reply No more flicker or ghost content. * fix: add fallback logging + 2 more edge case tests - Discord: tracing::warn on reply_to fallback (was silent) - Test: duplicate reply_to (last wins) - Test: CRLF with multiple directives Total directive tests: 10 * fix: relax message_id validation for cross-platform compatibility Was: numeric-only (Discord snowflake) Now: alphanumeric + dots + hyphens + underscores, max 64 chars This allows: - Discord snowflakes: 1502606076451885136 - Slack ts: 1234567890.123456 - UUID-style: 550e8400-e29b-41d4-a716-446655440000 Rejects: whitespace, control chars, empty, >64 chars Added test: parse_slack_ts_format_accepted Updated test: rejection now checks whitespace (not hyphens) * fix: guard against empty content after directive stripping If agent output is directive-only (e.g. just [[reply_to:123]] with no actual content), stripped_content would be empty. Discord rejects empty messages, causing silent failures. Fix: if content is empty/whitespace after stripping, fall back to '_(no response)_' — same behavior as when agent returns no text. * fix: delete placeholder instead of zero-width space on reply_to Adds delete_message to ChatAdapter trait (default no-op) and implements it for Discord. Streaming + reply_to path now deletes the placeholder entirely instead of editing to zero-width space. No more ghost empty bubbles in Discord threads. * fix: delete_message default falls back to edit zero-width space Per review: default no-op would leave placeholder visible if delete fails or adapter doesn't support it. Default now edits to zero-width space (existing behavior), Discord overrides with real delete. * fix: clippy errors (unnecessary_unwrap + too_many_arguments) - Replace is_some() + unwrap() with if let Some(ref reply_id) - Allow clippy::too_many_arguments on build_sender_context (8 params) * fix: log unknown directives at debug level Helps agent developers diagnose typos like [[reply-to:...]] vs [[reply_to:...]]. Forward compatible: unknown keys still ignored at runtime, just logged for debugging. * fix: remaining clippy unnecessary_unwrap in streaming path * docs: note Slack reply_to is parsed but not yet implemented * docs: remove Future Directives section (avoid premature commitment) * fix: send-before-delete order + parse directives before markdown F1: Send reply first, then delete placeholder. If send fails, placeholder remains visible (no message loss for user). F2: Parse directives before markdown::convert_tables. Directives are meta-layer and should be stripped before content transforms. * fix: parse directives from raw text_buf + check send before delete F1: Directives now parsed from raw text_buf BEFORE compose_display, ensuring tool call output cannot interfere with directive parsing. F3: Send result is checked — placeholder only deleted if first chunk sends successfully. On send failure, placeholder remains visible (no message loss). * fix: [[X]] without colon stops parsing (preserves agent content) B2: Lines like [[Note]], [[Summary]], [[Thought]] (no colon) are legitimate agent content, not directives. Parser now only advances content_start when split_once(':') succeeds. Without colon → break. Added test: parse_bracket_without_colon_preserved * docs: fix value spec accuracy + document duplicate-key behavior D1: Value spec now correctly describes cross-platform validation (non-empty, ≤64 chars, no whitespace) instead of 'numeric only'. D2: Added rule: 'If the same key appears multiple times, last wins.' Also: clarified [[X]] without colon stops parsing. * fix: add warn logging on send/delete failure + align docs with parser - tracing::warn on reply send failure (ops can diagnose permission issues) - tracing::warn on placeholder delete failure - Docs: 'no whitespace' → 'ASCII alphanumeric plus ., -, _' (matches code) --------- Co-authored-by: chaodu-agent <chaodu-agent@users.noreply.github.com>
1 parent 7bf7f63 commit c06e0e6

7 files changed

Lines changed: 361 additions & 7 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ A lightweight, secure, cloud-native ACP harness that bridges **Discord, Slack**,
4242
- **@mention trigger** — mention the bot in an allowed channel to start a conversation
4343
- **Thread-based multi-turn** — auto-creates threads; no @mention needed for follow-ups
4444
- **Multi-agent collaboration** — bot-to-bot messaging for coordinated workflows ([docs/multi-agent.md](docs/multi-agent.md))
45+
- **Agent-controlled reply-to** — agents choose which message to reply to via `[[reply_to:id]]` directive, enabling clear conversation threads in multi-bot channels ([docs/output-directives.md](docs/output-directives.md))
4546
- **Edit-streaming** — live-updates the Discord message every 1.5s as tokens arrive
4647
- **Emoji status reactions** — 👀→🤔→🔥/👨‍💻/⚡→👍+random mood face
4748
- **Image & file support** — send images and files through chat ([docs/sendimages.md](docs/sendimages.md), [docs/sendfiles.md](docs/sendfiles.md))

docs/output-directives.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# Output Directives
2+
3+
## Overview
4+
5+
Agents can control platform-specific message delivery by prefixing their output with `[[key:value]]` directives. OAB parses and strips these before sending to the platform.
6+
7+
## Format
8+
9+
```
10+
[[reply_to:1502606076451885136]]
11+
[[ephemeral:true]] ← future
12+
Actual message content starts here...
13+
```
14+
15+
Rules:
16+
- Consecutive `[[key:value]]` lines at the start of output = directive header block
17+
- First line that doesn't match `[[key:value]]` (with colon) = content begins
18+
- `[[X]]` without colon is NOT a directive — stops parsing, preserved as content
19+
- Directives are stripped from the final message (never visible to users)
20+
- Unknown keys are silently ignored (forward compatible, logged at debug level)
21+
- If the same key appears multiple times, the last value wins
22+
23+
## Available Directives
24+
25+
### `reply_to`
26+
27+
Reply to a specific message by ID (Discord: `message_reference`).
28+
29+
```
30+
[[reply_to:1502606076451885136]]
31+
Here is my reply to that specific message.
32+
```
33+
34+
**Value**: Platform message ID. Format depends on the target adapter — Discord requires a numeric snowflake; Slack accepts `ts` (e.g. `1234567890.123456`). The directive parser validates that the value is non-empty, ≤64 chars, and contains only ASCII alphanumeric characters plus `.`, `-`, `_`; per-platform format validation happens in each adapter.
35+
36+
**Behavior**:
37+
- Discord: sends with `message_reference`, showing the native "replying to..." UI
38+
- Invalid/non-existent message ID: silently falls back to plain send
39+
- Works in both streaming and send-once modes
40+
41+
**How agents get message IDs**: Every incoming message includes `message_id` in `SenderContext`:
42+
43+
```json
44+
{
45+
"schema": "openab.sender.v1",
46+
"sender_id": "845835116920307722",
47+
"sender_name": "pahud.hsieh",
48+
"message_id": "1502606076451885136",
49+
"channel": "discord",
50+
...
51+
}
52+
```
53+
54+
## Multi-Agent Use Case
55+
56+
In a thread with multiple bots, agents can reply to each other's messages:
57+
58+
```
59+
Human: "Review this PR" (message_id: 100)
60+
Bot A: "Found 3 issues" (message_id: 101)
61+
Bot B output:
62+
[[reply_to:101]]
63+
I agree with Bot A on F1, but F2 is actually fine because...
64+
```
65+
66+
This creates clear visual conversation threads within a Discord thread — essential for multi-agent collaboration.
67+
68+
## Comparison with Other Platforms
69+
70+
| Platform | Reply Mechanism | Agent Control |
71+
|----------|----------------|---------------|
72+
| OpenClaw | `replyToMode` config (`off`/`first`/`all`) | ❌ Platform decides, always to trigger msg |
73+
| Hermes Agent | `DISCORD_REPLY_TO_MODE` env var | ❌ Platform decides, always to trigger msg |
74+
| **OAB** | `[[reply_to:message_id]]` directive | ✅ Agent chooses any message |
75+
76+
> **Note:** `reply_to` is currently implemented for Discord only. Slack message IDs (ts format like `1234567890.123456`) are accepted by the parser but the Slack adapter does not yet send threaded replies via this directive — it falls back to plain send. Slack support can be added in a future PR.

src/adapter.rs

Lines changed: 239 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,63 @@ use crate::format;
1111
use crate::markdown::{self, TableMode};
1212
use crate::reactions::StatusReactionController;
1313

14+
// --- Output directive parsing ---
15+
16+
/// Parsed directives from agent output header block.
17+
/// Consecutive `[[key:value]]` lines at the start of output are directives.
18+
#[derive(Default, Debug)]
19+
pub struct OutputDirectives {
20+
/// Message ID to reply to (Discord: message_reference)
21+
pub reply_to: Option<String>,
22+
}
23+
24+
/// Parse `[[key:value]]` directives from the beginning of agent output.
25+
/// Returns parsed directives and the remaining content (directives stripped).
26+
pub fn parse_output_directives(content: &str) -> (OutputDirectives, String) {
27+
let mut directives = OutputDirectives::default();
28+
let mut content_start = 0;
29+
30+
for line in content.lines() {
31+
let trimmed = line.trim();
32+
if let Some(inner) = trimmed.strip_prefix("[[").and_then(|s| s.strip_suffix("]]")) {
33+
if let Some((key, value)) = inner.split_once(':') {
34+
match key.trim() {
35+
"reply_to" => {
36+
let v = value.trim();
37+
// Validate: non-empty, reasonable length, no whitespace/control chars
38+
if !v.is_empty() && v.len() <= 64 && v.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_') {
39+
directives.reply_to = Some(v.to_string());
40+
}
41+
}
42+
_ => {
43+
tracing::debug!(key = key.trim(), "unknown output directive ignored");
44+
}
45+
}
46+
// Advance past this line + its line ending (handles both \n and \r\n)
47+
content_start += line.len();
48+
if content.as_bytes().get(content_start) == Some(&b'\r') {
49+
content_start += 1;
50+
}
51+
if content.as_bytes().get(content_start) == Some(&b'\n') {
52+
content_start += 1;
53+
}
54+
} else {
55+
// [[X]] without colon — not a directive, stop parsing
56+
break;
57+
}
58+
} else {
59+
break;
60+
}
61+
}
62+
63+
let remaining = if content_start < content.len() {
64+
&content[content_start..]
65+
} else {
66+
""
67+
};
68+
(directives, remaining.to_string())
69+
}
70+
1471
// --- Platform-agnostic types ---
1572

1673
/// Identifies a channel or thread across platforms.
@@ -106,6 +163,10 @@ pub struct SenderContext {
106163
/// breakage). If future additions require breaking changes, bump to v1.1+.
107164
#[serde(skip_serializing_if = "Option::is_none")]
108165
pub timestamp: Option<String>,
166+
/// Platform message ID. Agents can use this to reply to a specific message
167+
/// via the `[[reply_to:<message_id>]]` output directive.
168+
#[serde(skip_serializing_if = "Option::is_none")]
169+
pub message_id: Option<String>,
109170
}
110171

111172
// --- ChatAdapter trait ---
@@ -141,6 +202,24 @@ pub trait ChatAdapter: Send + Sync + 'static {
141202
Err(anyhow::anyhow!("edit_message not supported"))
142203
}
143204

205+
/// Send a message as a reply to a specific message (Discord: message_reference).
206+
/// Default: falls back to plain send_message (ignores reply_to).
207+
async fn send_message_with_reply(
208+
&self,
209+
channel: &ChannelRef,
210+
content: &str,
211+
reply_to_message_id: &str,
212+
) -> Result<MessageRef> {
213+
let _ = reply_to_message_id; // unused in default impl
214+
self.send_message(channel, content).await
215+
}
216+
217+
/// Delete a message. Used to remove streaming placeholders when reply_to is set.
218+
/// Default: edits to zero-width space (fallback for platforms without delete support).
219+
async fn delete_message(&self, msg: &MessageRef) -> Result<()> {
220+
self.edit_message(msg, "\u{200b}").await
221+
}
222+
144223
/// Whether this adapter should use streaming edit (true) or send-once (false).
145224
/// `other_bot_present` indicates if another bot has posted in the current thread.
146225
/// Streaming should be disabled in multi-bot threads to avoid edit interference.
@@ -536,6 +615,12 @@ impl AdapterRouter {
536615
// Stop the edit loop
537616
drop(buf_tx);
538617

618+
// Parse output directives from raw text_buf BEFORE compose_display.
619+
// Directives are agent meta-layer, not content — must be stripped
620+
// before tool lines are composed into the display output.
621+
let (directives, stripped_text) = parse_output_directives(&text_buf);
622+
let text_buf = stripped_text;
623+
539624
// Build final content
540625
let final_content =
541626
compose_display(&tool_lines, &text_buf, false, tool_display);
@@ -554,17 +639,61 @@ impl AdapterRouter {
554639
let final_content = markdown::convert_tables(&final_content, table_mode);
555640
let chunks = format::split_message(&final_content, message_limit);
556641
if let Some(msg) = placeholder_msg {
557-
// Streaming: edit first chunk into placeholder, send rest as new messages
558-
if let Some(first) = chunks.first() {
559-
let _ = adapter.edit_message(&msg, first).await;
560-
}
561-
for chunk in chunks.iter().skip(1) {
562-
let _ = adapter.send_message(&thread_channel, chunk).await;
642+
if let Some(ref reply_id) = directives.reply_to {
643+
// reply_to directive: send reply first, then delete placeholder.
644+
// Only delete if send succeeds — preserves placeholder on failure.
645+
let mut send_ok = false;
646+
let mut first = true;
647+
for chunk in &chunks {
648+
if first {
649+
match adapter.send_message_with_reply(
650+
&thread_channel,
651+
chunk,
652+
reply_id,
653+
).await {
654+
Ok(_) => { send_ok = true; }
655+
Err(e) => {
656+
tracing::warn!(error = ?e, "reply_to send failed; preserving placeholder");
657+
}
658+
}
659+
} else {
660+
let _ = adapter.send_message(&thread_channel, chunk).await;
661+
}
662+
first = false;
663+
}
664+
if send_ok {
665+
if let Err(e) = adapter.delete_message(&msg).await {
666+
tracing::warn!(error = ?e, "delete placeholder failed; placeholder will remain visible");
667+
}
668+
}
669+
} else {
670+
// Normal streaming: edit first chunk into placeholder, send rest
671+
if let Some(first) = chunks.first() {
672+
let _ = adapter.edit_message(&msg, first).await;
673+
}
674+
for chunk in chunks.iter().skip(1) {
675+
let _ = adapter.send_message(&thread_channel, chunk).await;
676+
}
563677
}
564678
} else {
565679
// Send-once: all chunks as new messages
680+
// First chunk uses reply_to directive if present
681+
let mut first = true;
566682
for chunk in &chunks {
567-
let _ = adapter.send_message(&thread_channel, chunk).await;
683+
if first {
684+
if let Some(ref reply_id) = directives.reply_to {
685+
let _ = adapter.send_message_with_reply(
686+
&thread_channel,
687+
chunk,
688+
reply_id,
689+
).await;
690+
} else {
691+
let _ = adapter.send_message(&thread_channel, chunk).await;
692+
}
693+
} else {
694+
let _ = adapter.send_message(&thread_channel, chunk).await;
695+
}
696+
first = false;
568697
}
569698
}
570699

@@ -879,3 +1008,106 @@ mod tests {
8791008
assert_eq!(out, "response text");
8801009
}
8811010
}
1011+
1012+
#[cfg(test)]
1013+
mod directive_tests {
1014+
use super::parse_output_directives;
1015+
1016+
#[test]
1017+
fn parse_reply_to_directive() {
1018+
let input = "[[reply_to:1502606076451885136]]\nHello world";
1019+
let (directives, content) = parse_output_directives(input);
1020+
assert_eq!(directives.reply_to, Some("1502606076451885136".to_string()));
1021+
assert_eq!(content, "Hello world");
1022+
}
1023+
1024+
#[test]
1025+
fn parse_no_directives() {
1026+
let input = "Just plain content\nwith multiple lines";
1027+
let (directives, content) = parse_output_directives(input);
1028+
assert_eq!(directives.reply_to, None);
1029+
assert_eq!(content, input);
1030+
}
1031+
1032+
#[test]
1033+
fn parse_multiple_directives() {
1034+
let input = "[[reply_to:123456]]\n[[unknown_key:value]]\nContent here";
1035+
let (directives, content) = parse_output_directives(input);
1036+
assert_eq!(directives.reply_to, Some("123456".to_string()));
1037+
assert_eq!(content, "Content here");
1038+
}
1039+
1040+
#[test]
1041+
fn parse_invalid_reply_to_rejects_whitespace() {
1042+
let input = "[[reply_to:has spaces]]\nContent";
1043+
let (directives, content) = parse_output_directives(input);
1044+
assert_eq!(directives.reply_to, None);
1045+
assert_eq!(content, "Content");
1046+
}
1047+
1048+
#[test]
1049+
fn parse_slack_ts_format_accepted() {
1050+
let input = "[[reply_to:1234567890.123456]]\nContent";
1051+
let (directives, content) = parse_output_directives(input);
1052+
assert_eq!(directives.reply_to, Some("1234567890.123456".to_string()));
1053+
assert_eq!(content, "Content");
1054+
}
1055+
1056+
#[test]
1057+
fn parse_empty_reply_to() {
1058+
let input = "[[reply_to:]]\nContent";
1059+
let (directives, content) = parse_output_directives(input);
1060+
assert_eq!(directives.reply_to, None);
1061+
assert_eq!(content, "Content");
1062+
}
1063+
1064+
#[test]
1065+
fn parse_crlf_line_endings() {
1066+
let input = "[[reply_to:999]]\r\nContent with CRLF";
1067+
let (directives, content) = parse_output_directives(input);
1068+
assert_eq!(directives.reply_to, Some("999".to_string()));
1069+
assert_eq!(content, "Content with CRLF");
1070+
}
1071+
1072+
#[test]
1073+
fn parse_directive_only_no_content() {
1074+
let input = "[[reply_to:123]]";
1075+
let (directives, content) = parse_output_directives(input);
1076+
assert_eq!(directives.reply_to, Some("123".to_string()));
1077+
assert_eq!(content, "");
1078+
}
1079+
1080+
#[test]
1081+
fn parse_non_directive_line_stops_parsing() {
1082+
let input = "Normal first line\n[[reply_to:123]]\nMore content";
1083+
let (directives, content) = parse_output_directives(input);
1084+
assert_eq!(directives.reply_to, None);
1085+
assert_eq!(content, input);
1086+
}
1087+
1088+
#[test]
1089+
fn parse_duplicate_reply_to_last_wins() {
1090+
let input = "[[reply_to:111]]\n[[reply_to:222]]\nContent";
1091+
let (directives, content) = parse_output_directives(input);
1092+
// Last value wins
1093+
assert_eq!(directives.reply_to, Some("222".to_string()));
1094+
assert_eq!(content, "Content");
1095+
}
1096+
1097+
#[test]
1098+
fn parse_crlf_multiple_directives() {
1099+
let input = "[[reply_to:456]]\r\n[[unknown:x]]\r\nContent after CRLF";
1100+
let (directives, content) = parse_output_directives(input);
1101+
assert_eq!(directives.reply_to, Some("456".to_string()));
1102+
assert_eq!(content, "Content after CRLF");
1103+
}
1104+
1105+
#[test]
1106+
fn parse_bracket_without_colon_preserved() {
1107+
// [[Note]] has no colon — not a directive, preserved as content
1108+
let input = "[[Summary]]\nThis is body text";
1109+
let (directives, content) = parse_output_directives(input);
1110+
assert_eq!(directives.reply_to, None);
1111+
assert_eq!(content, input);
1112+
}
1113+
}

src/cron.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,7 @@ async fn fire_cronjob(
399399
.or(Some(reply_channel.channel_id.clone())),
400400
is_bot: true,
401401
timestamp: Some(Utc::now().to_rfc3339()),
402+
message_id: None, // cron jobs don't originate from a message
402403
};
403404
let sender_json = match serde_json::to_string(&sender) {
404405
Ok(j) => j,

0 commit comments

Comments
 (0)