fix: recover stranded replies in message_tool_only mode (#85714)#88992
fix: recover stranded replies in message_tool_only mode (#85714)#88992Jerry-Xin wants to merge 19 commits into
Conversation
|
Codex review: needs real behavior proof before merge. Reviewed July 9, 2026, 12:21 PM ET / 16:21 UTC. Summary PR surface: Source +159, Tests +282, Docs +2, Generated 0, Other +144. Total +587 across 17 files. Reproducibility: yes. Source inspection shows current main warns for a substantive Review metrics: 2 noteworthy metrics.
Root-cause cluster Members:
Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything. Merge readiness Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch. Rank-up moves:
Proof guidance:
Mantis proof suggestion Risk before merge
Maintainer options:
Next step before merge
Maintainer decision needed
Security Review detailsBest possible solution: Land only after maintainers accept the default-off recovery contract and a redacted real external channel run shows one sanitized retry delivering via Do we have a high-confidence way to reproduce the issue? Yes. Source inspection shows current main warns for a substantive Is this the best way to solve the issue? Unclear until maintainer intent is confirmed. A default-off one-shot retry is safer than auto-publishing private final text, but it is still a new recovery/config policy and the external transport proof is not yet sufficient. AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against 148ec3282f07. Label changesLabel changes:
Label justifications:
Evidence reviewedPR surface: Source +159, Tests +282, Docs +2, Generated 0, Other +144. Total +587 across 17 files. View PR surface stats
What I checked:
Likely related people:
What the crustacean ranks mean
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics. How this review workflow works
Review history (30 earlier review cycles; latest 8 shown)
|
145458b to
4f36338
Compare
|
Rebased on latest upstream/main (965e680, clean rebase, no conflicts). |
4f36338 to
45a163a
Compare
|
Rebased on latest upstream/main (646df2d, clean rebase, no conflicts). |
45a163a to
84a9b08
Compare
|
Rebased on latest upstream/main (8a9acd2, clean rebase, no conflicts). |
84a9b08 to
0ea5a6c
Compare
|
Rebased on latest upstream/main (57ea5af, clean rebase, no conflicts). |
0ea5a6c to
d3aa6b6
Compare
|
Rebased on latest upstream/main (ffbd02f, clean rebase, no conflicts). |
d3aa6b6 to
b76e9fc
Compare
|
Rebased on latest upstream/main (85d2dd8, clean rebase, no conflicts). |
76e92dc to
82aad34
Compare
|
@clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
82aad34 to
85c566e
Compare
|
@clawsweeper re-review Rewrote the approach: replaced suppression-bypass delivery with followup retry. The agent now gets a second chance to call |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
|
Rebased on latest upstream/main (827f2c4, clean rebase, no conflicts). @clawsweeper re-review |
|
Rebased on latest upstream/main (7e03242, clean rebase, no conflicts). @clawsweeper re-review |
|
Rebased on latest upstream/main (e25fa79, clean rebase, no conflicts). @clawsweeper re-review |
|
Rebased on latest upstream/main (534ace4, clean rebase, no conflicts). @clawsweeper re-review |
|
Rebased on latest upstream/main (45d0970, clean rebase, no conflicts). @clawsweeper re-review |
|
Rebased on latest upstream/main (6e4670f, clean rebase, no conflicts). @clawsweeper re-review |
|
Rebased on latest upstream/main (fae5421, clean rebase, no conflicts). @clawsweeper re-review |
|
Rebased on latest upstream/main (4f9a371, clean rebase, no conflicts). @clawsweeper re-review |
When messages.visibleReplies is set to message_tool, the LLM is expected to call the message tool for delivery. If the LLM forgets, the reply was silently discarded — the substantive text sat in the JSONL transcript but was never delivered to the user. The existing shouldWarnAboutPrivateMessageToolFinal() detection was already identifying these stranded replies (logging a warning), but took no recovery action. This commit promotes the detection from log-only to active recovery: 1. Mark stranded reply payloads with deliverDespiteSourceReplySuppression so dispatch-from-config delivers them in the same turn despite the source reply suppression policy. 2. Persist pendingFinalDelivery with the stranded text and delivery context as a crash-safety write-ahead log — if the process restarts between persistence and delivery, the heartbeat / restart-recovery mechanism will retry. 3. Add skipSuppressionCheck to resolveReplyRunDeliveryContext so the stranded reply can obtain a valid delivery context even when suppressDelivery is active. Normal (non-stranded) paths are unaffected: short replies, intentional NO_REPLY, and runs where the message tool already delivered are all excluded by the existing shouldWarnAboutPrivateMessageToolFinal guard. Closes openclaw#85714
… mode (openclaw#85714) Detect stranded replies (substantive text without message tool call) and enqueue a followup retry turn that prompts the agent to deliver via message(action=send), preserving the message_tool_only privacy contract. Changes from upstream/main: - Add room_event gate: skip stranded detection for automatic message_tool_only on ambient room events (intentional silence) - Enqueue followup retry with the captured assistant text instead of marking payloads for suppression bypass - Clear transcriptPrompt, userTurnTranscriptRecorder, and currentInboundContext on the retry run to prevent the original user prompt from hiding the retry instruction in the followup runner - Keep pendingText/pendingFinalDelivery unchanged (no bypass, no skipSuppressionCheck) Normal paths unaffected: short replies, intentional NO_REPLY, and runs where the message tool already delivered are excluded by the existing shouldWarnAboutPrivateMessageToolFinal guard.
A stranded message_tool_only reply enqueues a followup retry turn so the agent can deliver via message(action=send). That retry turn can itself fail to call message(action=send); without a guard it would re-enter the same path and enqueue another retry every turn, looping forever and re-attempting channel delivery each time. Mark the recovery turn with a shared STRANDED_REPLY_RETRY_MARKER and skip re-enqueueing when the current run is already a stranded-reply retry, falling back to the existing message_tool_only suppression behavior. Adds a regression test asserting no second retry is enqueued when a stranded-reply retry strands again.
The runPrivateFinalCase helper declared resolvedVerboseLevel as a plain string, which is not assignable to the VerboseLevel field it feeds. Import the VerboseLevel type and narrow the param to fix the test typecheck.
Add a deterministic qa-lab scenario plus the matching mock-openai decision branches to reproduce the openclaw#85714 recovery loop end to end: a substantive message_tool_only final that omits the message tool strands, the gateway warns and enqueues exactly one stranded-reply retry, and the retry delivers the original reply via message(action=send) without looping. Drives the real runReplyAgent -> enqueueFollowupRun -> followup-runner -> message(action=send) -> qa-channel outbound path rather than the unit mocks.
…openclaw#85714) The stranded-reply recovery retry builds a synthetic prompt that quotes the private message_tool_only final text. The enqueued followup run did not set suppressNextUserMessagePersistence, so that synthetic prompt could be persisted as a normal user turn and leak the private final text into durable session context. The followup runner reads suppression at run.* level, so set run.suppressNextUserMessagePersistence on the retry. Add focused regression coverage asserting the flag is set on the enqueued retry run.
…sage_tool_only contract (openclaw#85714) The stranded-reply recovery retry changed the documented message_tool_only contract (no message call = no source reply; final text stays private). Gate the recovery behind a new messages.strandedReplyRecovery flag, default off, so the documented default behavior is preserved verbatim and only opt-in setups get a single recovery retry. The diagnostic warning still fires unconditionally so a stranded final remains observable even with recovery off. Also strengthen the message_tool_only system-prompt guidance so the model is more reliably reminded it must call message(action=send) to deliver a substantive final, reducing the stranding rate at the source. Adds schema, help/label entries, and tests covering default-off (warn, no enqueue) and opt-in (single bounded retry, persistence suppressed) paths.
…law#85714) Document the new opt-in stranded-reply recovery flag in the tool-only visible replies section: default-off behavior preserves the documented contract (no message call = no source reply, final stays private, diagnostic warning still fires), and messages.strandedReplyRecovery: true enqueues a single bounded recovery retry with persistence suppressed. Also reference it from the typing-then-silence troubleshooting fix list. Regenerate the config doc baseline hash for the new schema field.
…io (openclaw#85714) The stranded-reply recovery is now opt-in (messages.strandedReplyRecovery, default off). The qa-lab recovery scenario exercises the recovery path, so its gatewayConfigPatch must enable the flag; otherwise the scenario waits for a delivery that never happens under the default-off contract and times out. Set strandedReplyRecovery: true alongside visibleReplies: message_tool.
…penclaw#85714) The opt-in stranded-reply recovery enqueued its synthetic retry through the session queue with the live settings. Under messages.queue.mode=collect, the drain path could merge that retry with other queued prompts into one batched turn, diluting the exact retry instruction and dropping its summaryLine marker (which also defeated the one-shot loop guard). Add a per-item disableCollectBatching flag on FollowupRun, set it on the stranded-reply retry, and route any flagged item through the individual drain path so its exact prompt and summaryLine survive. Adds collect-mode regression coverage.
The stranded-reply recovery retry enqueued its followup with restartIfIdle=true, which could kick the followup drain while the active reply operation still owned the lane, racing the owner-clear handoff. Mirror the normal enqueue-followup path: enqueue with restartIfIdle=false and route the drain through scheduleFollowupDrainAfterReplyOperationClear so the retry only runs once the active operation releases the lane. Add a focused regression test asserting the drain stays dormant until the reply operation clears.
The recovery scenario relies on mock-openai seeded responses to strand the first final and deliver the retry, but it did not declare requiredProviderMode or assert the provider mode. Add the mock-openai provider-mode guard (config + upfront assert) matching the sibling message-tool-stranded-final-reply scenario, so the proof always runs on the deterministic stranded-final branch and is filtered out of seedless provider lanes instead of running nondeterministically.
Correct the strandedReplyRecovery documentation to accurately describe persistence. suppressNextUserMessagePersistence suppresses only the synthetic retry user prompt, so the replayed private final text is not written to durable session context as a user turn. A successful message(action=send) retry, however, follows normal message-tool transcript persistence: the delivered reply text and its tool-call args are recorded like any other message send. The prior wording overstated this by implying the text never persists at all.
…e-tool transcript The messages.strandedReplyRecovery config help only mentioned that the synthetic retry prompt has its persistence suppressed, which understated the full behavior. A successful retry delivers via message(action=send) and that delivered reply text persists normally through the message-tool transcript path, matching the fuller wording already in the type comment (config/types.messages.ts) and the channel docs. Update the short help string to state both halves and regenerate the config-doc baseline.
…data (openclaw#85714) The one-shot stranded-reply recovery retry used followupRun.summaryLine as its loop guard, but summaryLine is also the queue's human/summary display field (used by drop/summarize/overflow handling). Coupling the private loop guard to a display field risked the guard leaking into queue summaries or drifting from private control state. Move the guard onto a dedicated private FollowupRun.strandedReplyRetry boolean. summaryLine is retained only as the display label. The guard site now reads the boolean; the enqueued retry sets both the boolean guard and the display label. disableCollectBatching keeps the retry draining individually, so the private field survives to the guard read.
…85714) The stranded-reply recovery retry embedded the raw private final text into a synthetic prompt asking the agent to redeliver via message(action=send). The message tool sanitizes runtime/inbound metadata but not reply directives, so directive tokens ([[reply_to:...]], [[audio_as_voice]], MEDIA: lines) in the final could survive the retry and be re-sent unsanitized, breaking the message_tool_only privacy/directive boundary. Run the same normalization the normal final-delivery path applies (normalizeReplyPayloadDirectives) on the final text before quoting it in the retry prompt, so the retry replays only the normalized visible text. Add regression coverage: a directive-laden final has its directives stripped from the retry prompt while the visible message survives and the recovery guard flags persist; a directive-free final is quoted unchanged.
|
Rebased on latest upstream/main (2f057e1, clean rebase, no conflicts). @clawsweeper re-review |
|
Closing as superseded: #99536 landed in ae63a48 and fixes #85714 with default-on recovery (one protected retry, then a sanitized visible diagnostic), so the opt-in Thanks for working on this, and sorry we ended up landing a different shape. If you hit a case the landed behavior does not recover, please open a fresh issue with the session details. |
Summary
When
messages.visibleRepliesis set tomessage_tool, the agent is expected to call the message tool to deliver replies. If the LLM forgets (a probabilistic event — the reporter saw it happen on the 8th turn after 7 correct deliveries), the reply was silently discarded: the substantive text sat in the JSONL transcript but was never delivered to the user.The existing
shouldWarnAboutPrivateMessageToolFinal()detection was already identifying these stranded replies and logging a warning, but took no recovery action.Fix
This commit promotes the detection from log-only to followup retry, preserving the
message_tool_onlyprivacy contract:Gate room events: Skip stranded reply detection for ambient
room_eventturns wheremessage_tool_onlyis automatically set by the system. Silence on room events is intentional by design — the agent chose not to respond.Enqueue followup retry turn: When a stranded reply is detected, enqueue a followup run whose prompt instructs the agent to deliver the captured text via
message(action=send). The agent itself calls the message tool, so delivery happens through the correct contract path — no suppression bypass, no privacy boundary crossing.Clear transcript/persistence fields: The retry run explicitly clears
transcriptPrompt,userTurnTranscriptRecorder, andcurrentInboundContextfrom the original followup run to prevent the original user prompt from hiding the retry instruction. The followup runner resolvestranscriptPrompt ?? extracted.text, so a retainedtranscriptPromptwould cause the agent to see the original user question instead of the retry delivery instruction.No suppression bypass: This does NOT mark payloads with
deliverDespiteSourceReplySuppressionor useskipSuppressionCheck. Themessage_tool_onlyprivacy contract is fully preserved: delivery only happens when the agent explicitly callsmessage(action=send).Normal (non-stranded) paths are unaffected: short replies, intentional
NO_REPLY, and runs where the message tool already delivered are all excluded by the existingshouldWarnAboutPrivateMessageToolFinalguard.Why followup retry instead of bypass delivery?
The
message_tool_onlycontract says: "no message tool call = private/silent". Auto-publishing stranded text would break this documented behavior. Instead, we give the agent a second chance to follow the contract correctly — like returning an undelivered letter and asking the sender to mail it properly, rather than opening and publishing it ourselves.Changed files
src/auto-reply/reply/agent-runner.ts— followup retry with cleaned transcript fields, room_event gate (+26/-3 net)src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts— 5 new retry/gate tests replacing 6 bypass tests (-4 net)Tests
agent-runner.misc.runreplyagent.test.tsprivate-message-tool-final.test.tsdispatch-from-config.test.tsfollowup-runner.test.tsNew test cases:
enqueues a followup retry for a stranded substantive reply (#85714)— verifiesenqueueFollowupRunis called with retry prompt containing the assistant textdoes not enqueue retry for short private final replies— negative casedoes not enqueue retry when the message tool delivered this turn— negative casedoes not enqueue retry for room_event turns (#85714)— verifies room_event gateclears transcriptPrompt on retry to prevent original prompt from hiding retry instruction (#85714)— regression test verifyingtranscriptPrompt,userTurnTranscriptRecorder, andcurrentInboundContextare allundefinedon the retry runReal behavior proof
message_tool_onlymode. The fix enqueues a followup retry turn so the agent can deliver viamessage(action=send), preserving the privacy contract.9dc3c76dc3.deliverDespiteSourceReplySuppressionorskipSuppressionCheck. No payload is auto-delivered. The agent is re-prompted to callmessage(action=send)itself.transcriptPrompt,userTurnTranscriptRecorder, andcurrentInboundContextso the followup runner'stranscriptPrompt ?? extracted.textresolution correctly uses the retry prompt, not the original user message.sessionCtx.InboundEventKind !== "room_event", so ambient room events (where silence is intentional) never trigger recovery. Covered by dedicated test case.upstream/main,warnPrivateMessageToolFinalonly logs — no retry is enqueued and the reply is silently lost.enqueueFollowupRunis called with a retry prompt containing the original assistant text and instructions to callmessage(action=send). The retry run hastranscriptPrompt: undefinedso the followup runner correctly uses the retry instruction. Short replies, already-delivered replies, room events, and runs with existing transcript prompts are all verified by dedicated test cases.enqueueFollowupRun→scheduleFollowupDrain→runReplyAgent) is a well-tested existing mechanism used for concurrent message queuing.Real behavior proof (end-to-end, qa-lab host runner)
This adds a deterministic end-to-end proof that drives the real runtime loop —
runReplyAgent → stranded-final detection → warnPrivateMessageToolFinal → enqueueFollowupRun("stranded-reply-retry") → followup-runner → runEmbeddedAgent → message(action=send) → qa-channel outbound— not the unit mocks (which stubenqueueFollowupRun/runEmbeddedAgent).messages.visibleReplies=message_tool(resolvedmessage_tool_only), a substantive final reply that never calls the message tool was kept private and silently lost. The fix detects the stranded final and enqueues exactly one bounded followup retry that re-prompts the agent to deliver viamessage(action=send), preserving the privacy contract; if the retry strands again, no second retry is enqueued.fix/stranded-reply-pending-delivery-85714at current HEAD0469009210(rebased on latest upstream/main; recovery default-off + collect-batching isolation + updated scenario). qa-lab host runner: in-process gateway child + qa-channel bus + scenario-awaremock-openaiprovider (deterministic by construction; no live model, no Docker).plannedToolName !== "message"(long plain-text final, stranded).you did not call message(action=send).plannedToolName === "message",plannedToolArgs.action === "send",plannedToolArgs.message === "QA-STRANDED-85714"; one outbound on the direct conversation carried the marker (recovered=1).message_tool_onlyfinal was detected and warned, exactly one retry was enqueued, the retry delivered the original text viamessage(action=send)to the channel, and no second retry/delivery occurred — all four invariants observed against the real delivery path.message(action=send)→ source-conversation outbound contract is transport-agnostic core auto-reply behavior.Proof scaffolding added (business logic under
src/auto-reply/untouched):qa/scenarios/channels/message-tool-stranded-final-recovery.md(new scenario) andextensions/qa-lab/src/providers/mock-openai/server.ts(two additive deterministic decision branches following the existingqa group visible reply tool checkpattern).Closes #85714
Update — retry drain scheduling (follow-up)
Refined the stranded-reply retry enqueue so it no longer restarts the followup drain while the active reply operation still owns the lane. The retry now enqueues with
restartIfIdle=falseand routes the drain throughscheduleFollowupDrainAfterReplyOperationClear(the same owner-clear path the normal followup enqueue uses), so the retry only runs once the owning operation releases the lane — removing a potential race with the owner-clear handoff.src/auto-reply/reply/agent-runner.ts— stranded retry enqueue mirrors the normal enqueue-followup path (dormant enqueue + deferred drain).src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts— addedschedules the stranded-reply retry drain only after the active reply operation clears, asserting the drain stays dormant while the reply operation owns the lane and fires only after it clears.