feat(delivery): multi-message replies over ACP - #1509
Open
IveTian wants to merge 3 commits into
Open
Conversation
Turn one agent turn into several chat messages ("bubbles") instead of one wall
of text, plus the noise control a proactive agent needs to not become a
nuisance. Four opt-in pieces; an unconfigured deployment behaves byte-for-byte
as before.
Structured delivery — `[delivery] mode = "structured"`
The agent returns a versioned `openab.turn.v1` envelope and the broker sends
one platform message per bubble. A newline is deliberately NOT a message
boundary: multi-line content (an address, a code block) stays in one message.
Branches before `finalize_body`/`display_for`, both of which prepend text
that would otherwise make every envelope fail to parse.
Turn envelope — openab-agent
Replies go through a `reply` tool whose input schema IS the envelope, so the
provider API guarantees well-formed JSON rather than the prompt asking
nicely. Schema id, bubble ids and the `next` shape are filled in by code, so
the model cannot get them wrong. Voice reuses the existing AGENTS.md
mechanism; recipes reuse skills.rs.
Event triage — `[triage]`
Dedupe / quiet hours / cooldown / daily cap, evaluated before dispatch so a
suppressed event costs no LLM call. Applies ONLY to events flagged
`proactive`: quiet hours that swallow a question a human asked are an outage,
not a feature. Suppressions log a stable reason tag, because "the agent chose
to stay quiet" and "the broker never asked it" are otherwise
indistinguishable.
Sequential delivery — `mode = "sequential"`
A non-standard `openab_message` ACP extension delivers each bubble the moment
the agent decides it, so a later bubble can reflect a tool result the earlier
one triggered ("on it" → lookup → the answer). Costs one model call per
bubble; the envelope stays the recommended default. Agents that do not
implement it never emit the event and fall through to the text path — the
absence of an event is the negotiation.
The envelope is never visible. Raw or truncated JSON is stripped before any
fallback reaches the user, and a truncated envelope is deliberately classified
as "found" — being cut off mid-object is when leaking is most likely.
Two cross-crate fixtures pin the producer/consumer contracts, since
openab-agent is its own workspace and cannot call the broker's parser.
Design: docs/adr/structured-delivery.md
Verify: scripts/bubble-test/ — runs every path offline, no LLM key, no
platform account, no network
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nbc3CQ781crXdUJudAnyf3
All four confirmed against the source; no false positives. F3 (yellow) — `await_ack` skipped sequential mode The gateway gated the per-send ack on `mode == Structured`, so sequential bubbles went onto the WebSocket without waiting and could be reordered by the gateway — the exact hazard ADR §4 documents. Sequential needs it more than structured: its bubbles are milliseconds apart. The rule now has a name, `requires_send_ack`, so it has one home instead of being an inline comparison that the next mode can silently miss. F2 (yellow) — a capped turn reported as failed Reaching `max_bubbles` set the same flag a real send failure sets, so a turn that said everything it was allowed to say surfaced ❌ to the user. Cap and failure are now separate states, and the outcome rule is a pure helper (`sequential_outcome`) that can be tested without a live ACP session — the same reason `finalize_body` exists. F1 (green) — envelope detection could eat a JSON sample `looks_like_envelope` matched substrings, so prose showing the user a config with a `"schema"` key was treated as a malformed envelope and stripped. A complete fragment is now parsed rather than guessed at, and the marker is `messages` rather than `schema`: an envelope is by definition a list of messages, while a bare `schema` key is common in unrelated JSON. A truncated fragment cannot be parsed and keeps the loose substring check — over-stripping half an envelope is the safe direction to be wrong in. F4 (green) — truncated delivery reported as complete A `reply` call cut short by the cap still got "delivered", leaving the model believing it had said something the user never saw. The tool result now reports what actually went out. Each fix carries a regression test naming the finding it closes. broker 816 passed · agent 92 passed · clippy clean · unified builds Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nbc3CQ781crXdUJudAnyf3
feat(delivery): multi-message replies over ACP (structured / sequential / triage)
Contributor
|
Caution This PR is missing a Discord Discussion URL in the body. All PRs must reference a prior Discord discussion to ensure community alignment before implementation. Please edit the PR description to include a link like: |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What problem does this solve?
An agent turn arrives as one wall of text.
AdapterRouter::stream_prompt_blocksconcatenates everyagent_message_chunkintotext_bufand splits it only at the platform's length limit (format::split_message). That split is a transport concern: it carries no information about where one thought ends and the next begins, so an agent cannot say "on it" and then, a beat later, "your flight moved to 8pm". The second form is all OpenAB can produce today.A proactive agent has the mirror problem: every unsolicited event (a mail, a webhook retry, a 3am alert) costs an LLM call just to decide whether to stay quiet, and a stateless turn cannot tell a webhook retry from a fresh event.
This PR adds four opt-in pieces. An unconfigured deployment behaves byte-for-byte as before.
[delivery] mode = "structured"openab.turn.v1envelope; the broker sends one platform message per bubbleturn_envelope(openab-agent)replytool whose input schema is the envelope, so the provider API guarantees valid JSON[triage][delivery] mode = "sequential"openab_messageACP extensionA newline is deliberately not a message boundary — an address or a code block stays in one message. Splitting is by envelope entry only.
Design:
docs/adr/structured-delivery.mdCloses #
Discord Discussion URL:
Review Contract
Goal
Let an agent reply with deliberate conversational beats ("on it" → the answer) instead of a single block, and let a proactive agent decide not to speak without burning an LLM call on every routine notification — while guaranteeing that the machinery (the JSON envelope) is never visible to a user, under any failure.
Non-goals
next.type = toolis recorded and otherwise ignored. Real authorization belongs to the agent runtime and toopenab-mcp's existingtool_filter.openab-agentalready carries the recipe mechanism (skills.rs/SKILL.md) and persona override (AGENTS.md); neither needed new code here.pool::with_connectionholds the per-thread mutex throughout, so two turns cannot interleave and there is no retry path to make idempotent.openab.gateway.event.v1with one additiveproactiveflag, inheriting the trust gate, batching and session routing rather than re-implementing them.docs/adr/imessage-integration.md.Accepted Residual Risks
text.docs/native-agent.mdwith the bad rows marked "do not ship"; a schema mismatch is logged at agent startup. Not defended in code — the broker cannot see the agent's config.structuredremains the recommended default and this is documented as the experiment to run against it, not a replacement.[reactions] tool_displayandnarration_displayare ignored in both bubble modes. There is no single message to prefix with a tool summary, and an extra✅ 2 tool(s)bubble would break the reply's rhythm. Documented.stream_prompt_blocksruns inside awith_connectionclosure and needs a live ACP session. Mitigation: every decision it makes is extracted into a pure function with direct tests (requires_send_ack,sequential_outcome, the same patternfinalize_body/split_deliveryalready follow), andscripts/bubble-test/exercises the wired path end to end offline.openab-agentenforces it via tool schema. A model that drifts turns every reply into one fallback message. Mitigation:on_parse_error = "fallback_text"(default) still delivers the prose, with any envelope fragment stripped.Acceptance Criteria
Backward compatibility
[delivery]/[triage]section → behaviour identical to before; all pre-existing tests pass untouched (scripts/bubble-test/text.tomlis the regression baseline)proactivefield parses and is dispatched unconditionallyBubble semantics
alpha\nbeta\ngammain one bubble → exactly one platform messagebubble_delay_msapartmax_bubblesor overmax_bubble_chars→ the whole plan is rejected, never silently truncatednext: silentsends nothing, and wins over a non-emptymessagesThe envelope is never visible
on_parse_errorpolicy"schema"key) is not mangled by the stripper (F1 regression test)Displaycarries no bubble textTriage
event_id) produces no second messagereasontag (duplicate/quiet_hours/cooldown/daily_cap)Sequential
replyis non-terminal: the model is consulted again after the first bubblereplycall cut short by the cap reports to the model what was actually delivered (F4)Cross-crate contract
openab-agent's output and the broker's parser are pinned to shared fixtures indocs/fixtures/(turn-envelope-v1.json,sequential-message-v1.json), verified to fail on driftFollow-ups
next.type = toolis the seam left for it.sequentialagainststructuredon real traffic — the experiment this PR exists to enable.[[bubble]]text-marker probe (ADR §5.1) if the bubble experience needs A/B validation with agents that cannot be given a tool schema.DeliveryModeis an enum, so it slots in beside the envelope.At a Glance
Prior Art & Industry Research
OpenClaw:
OpenClaw's answer to "several bubbles per turn" is block streaming — a config-driven chunker over the model's free-text output, not a model-declared boundary.
blockStreamingDefaultturns it on;blockStreamingBreakchooses"text_end"(flush as the chunker emits) or"message_end";blockStreamingChunksetsminChars/maxChars/breakPreference(paragraph → newline → sentence → whitespace → hard break);blockStreamingCoalesce(idleMs,minChars,maxChars) merges small chunks to avoid single-line spam;humanDelay.mode: "natural"adds an 800–2500 ms randomized pause between blocks after the first. Code fences are closed and reopened across chunks to keep Markdown valid. The model has no way to control boundaries — there is no marker or tool. The failure mode of boundary-by-heuristic is visible in openclaw#31679: a short reply arrived as"Per"and"feito, @jarbas! 👊"in two Discord messages because content blocks were dispatched independently; closed as not planned.For proactive quiet, OpenClaw's heartbeat runs the model on a schedule and relies on a sentinel in the reply:
HEARTBEAT_OKat the start or end of the output suppresses delivery (only if the remainder is under 300 chars; a mid-reply sentinel is ignored).activeHours { start, end }defers ticks outside a window;showOk/showAlerts/useIndicatorgate what is shown, and an emptyHEARTBEAT.mdskips the run entirely. There is no dedupe of repeated events, and staying quiet costs one model call per tick unless the whole run is skipped.Hermes Agent:
Hermes splits only at the platform's length limit, then paces the pieces:
HERMES_DISCORD_TEXT_BATCH_DELAY_SECONDS(default0.6, grace window before flushing a queued chunk) andHERMES_DISCORD_TEXT_BATCH_SPLIT_DELAY_SECONDS(default2.0, delay between split chunks).DISCORD_REPLY_TO_MODE(off/first/all) decides which chunks carry a reply reference. Streaming is edit-in-place on platforms that allow it, andtool_progress_grouping(accumulatedefault, orseparatefor one message per tool) controls tool-progress bubbles. There is no mechanism for the model to deliberately emit several distinct messages in one turn, and no documented quiet-hours or per-event dedupe; cron ticks every 60 s and delivers whatever the job produces. What Hermes does have that we deliberately do not is a durable delivery ledger (gateway.delivery_ledger, at-least-once, "♻️ Recovered reply" prefix on ambiguous redelivery) — see Non-goals for why one turn's bubbles do not need it here.Other references (optional):
session/promptyields one response andagent_message_chunkis an append-only text stream — the protocol has no in-turn message boundary, which is why the boundary has to live either in the model's output (envelope) or in a non-standardsessionUpdate(sequential).[[reply_to:…]],docs/output-directives.md) and schema-id convention (openab.sender.v1,openab.gateway.reply.v1), both reused here.input_schemais validated by the provider, which is what letsopenab-agentmake the envelope a guarantee rather than a prompt request.Proposed Solution
1. Envelope, parsed in the broker (
crates/openab-core/src/structured_delivery.rs). A versioned wire typeopenab.turn.v1:{ "schema": "openab.turn.v1", "messages": [ { "id": "bubble_1", "text": "on it" }, { "id": "bubble_2", "text": "your flight moved to 8pm\ngate B12" } ], "next": { "type": "stop" } }deny_unknown_fieldson every struct;nextdefaults tostop;silentwins over non-emptymessagesat parse time so the delivery loop only ever readsbubbles;toolis recorded as a proposal and otherwise ignored. Overmax_bubbles/max_bubble_charsrejects the whole plan. Pure functions, no I/O.strip_enveloperemoves a complete parsed envelope, or — for a truncated fragment — falls back to a loose"messages"substring match, because over-stripping half an envelope is the safe direction to be wrong in.2. Router branch, decided at construction time.
AdapterRouter::with_delivery(mirrorswith_trust). Structured mode must be known before the turn starts: streaming and Slack native assistant mode are forced off, because a turn-final directive cannot stop half a JSON object from being edited into a live message (ADR §2.1 invariant I1). The branch runs beforefinalize_body/display_for, both of which prepend text that would otherwise make every envelope fail to parse. OneChatAdapter::send_messageper bubble,bubble_delay_msapart;GatewayAdaptergains a per-sendawait_ack(requires_send_ack) so a WebSocket gateway cannot reorder bubbles.3. Producer side (
openab-agent/src/turn_envelope.rs). Withturn_envelope = "openab.turn.v1", the agent registers areplytool whoseinput_schemais the envelope —messages: string[](capped bymaxItems) plusnext: "stop" | "wait" | "silent". Schema id, bubble ids and thenextobject shape are filled in by code, so the model cannot get them wrong. Text outside the tool is not delivered. Voice/persona reusesAGENTS.md; recipes reuseskills.rs.4. Event triage (
crates/openab-core/src/event_triage.rs). Dedupe byevent_id, quiet hours + daily rollover in a configured timezone, cooldown, daily cap. Runs after the trust gate (an unauthorized source cannot burn a conversation's allowance) and before dispatch (a suppressed event costs no LLM call). Applies only to events flaggedproactive: true— quiet hours that swallow a question a human asked are an outage, not a feature. Both gateway ingress paths share onetriage_gateway_event. Every suppression logs a structured line with a stablereasontag.5. Sequential extension (
mode = "sequential"). A non-standardsession/updatevariantopenab_message, sharingagent_message_chunk'scontent: {type, text}shape. The broker sends on arrival;openab-agent'sreplytool becomes non-terminal and losesnext(the turn ends when the model stops calling tools; silence is never callingreply). No capability negotiation: an agent that never emits the event falls through to the text path, and an event arriving at a broker not in sequential mode is logged loudly and ignored. On failure after the first bubble, the user keeps what arrived and the turn is marked failed.Why this approach?
replytool'sinput_schemameans the provider validates it. That removes the "model forgot the format" class of failure foropenab-agententirely; for other ACP agents the parser +fallback_textkeeps it non-fatal.[[delivery:…]]directive exists only as a per-turn schema override, never a switch.[triage]) decide whether the agent is woken at all; the model'snext: silentdecides whether it has anything worth saying. OpenClaw'sHEARTBEAT_OKis the second layer alone, which costs a model call per routine tick and cannot dedupe a webhook retry.ChatAdaptertrait change; Discord and Slack adapters untouched.Known limitations: structured mode has no token streaming (worse for long-form answers, hence global opt-in); sequential mode costs one model call per bubble; broker/agent config must be paired by the operator and is not validated across the process boundary.
Alternatives Considered
[[bubble]]on its own line), ADR §5.1. ~50 lines and composes with the existing directive syntax. Rejected as the primary mechanism: it cannot carrynext(silentin particular is first-class for a proactive agent), and a marker inside a fenced code block would split a bubble mid-content. Kept as a possible cheap A/B probe —DeliveryModeis an enum.sequential. But one model call per bubble is a real price and most replies do not need a mid-turn beat, so the envelope stays the recommended default rather than being replaced.docs/sendfiles.md). Rejected for text: it bypasses the trust gate, per-thread serialization,split_message,convert_tablesand the reaction lifecycle, and requires handing platform credentials to the agent — which AGENTS.md rule 3 exists to prevent.openab.gateway.event.v1already carries everything an inbound event needs; a parallel protocol would need the trust gate, batching, session routing, STT and attachments re-implemented or skipped — and "skipped" is how an unauthenticated path into the agent gets built. One additiveproactiveflag instead.HEARTBEAT_OK). Subsumed bynext: silent, which is a typed enum the provider validates rather than a string the broker has to find at the edge of the output.Validation
Rust changes:
cargo checkpasses, includingcargo check --features unifiedcargo testpasses (including new tests) — as recorded on the merged fork PR (zeabur/openab-multiturn#1): brokercargo test --workspace --lib1429 passed, 0 failed;openab-agent cargo test90 passed, 0 failed. After the review-fix commit (ff551a42, findings F1–F4, each with a regression test naming the finding): broker 816 passed (openab-core), agent 92 passed.cargo clippyclean on every touched file (three crates)Docs:
docs/adr/structured-delivery.md,docs/config-reference.md#delivery,docs/native-agent.md#multi-message-replies-turn-envelope,docs/output-directives.md,docs/fixtures/README.md— links resolve; the pairing table names the two combinations that must not ship.Manual testing —
scripts/bubble-test/runs every delivery path offline (fake WebSocket gateway + fake ACP agent replaying scripted turns; no LLM key, no platform account, no network). Steps:uv run scripts/fake-gateway.pyin one terminal,cargo run -- run -c scripts/bubble-test/<mode>.tomlin another, switchSCENARIOin the config's[agent] env. Observed:structured.toml/envelopebubble_delay_ms)structured.toml/multilinestructured.toml/silentstructured.toml/prosestructured.toml/brokensorry, one secand no{reaches the gateway; logstructured turn did not parse … policy=fallback_textstructured.toml/toolongparse_error_textline — overmax_bubbles, body was nothing but the envelopesequential.toml/seqslowbubble_delay_ms = 0— the pause is the agent's, proving each bubble was sent when decidedsequential.toml/seqhalftriage.toml/proactive flight delayedsuppressed withdecision="suppressed" reason="quiet_hours"; repeat →cooldown;/id evt_42twice →duplicate; 4th in a minute →daily_captext.toml