Skip to content

fix: recover stranded replies in message_tool_only mode (#85714)#88992

Closed
Jerry-Xin wants to merge 19 commits into
openclaw:mainfrom
Jerry-Xin:fix/stranded-reply-pending-delivery-85714
Closed

fix: recover stranded replies in message_tool_only mode (#85714)#88992
Jerry-Xin wants to merge 19 commits into
openclaw:mainfrom
Jerry-Xin:fix/stranded-reply-pending-delivery-85714

Conversation

@Jerry-Xin

@Jerry-Xin Jerry-Xin commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Summary

When messages.visibleReplies is set to message_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_only privacy contract:

  1. Gate room events: Skip stranded reply detection for ambient room_event turns where message_tool_only is automatically set by the system. Silence on room events is intentional by design — the agent chose not to respond.

  2. 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.

  3. Clear transcript/persistence fields: The retry run explicitly clears transcriptPrompt, userTurnTranscriptRecorder, and currentInboundContext from the original followup run to prevent the original user prompt from hiding the retry instruction. The followup runner resolves transcriptPrompt ?? extracted.text, so a retained transcriptPrompt would cause the agent to see the original user question instead of the retry delivery instruction.

  4. No suppression bypass: This does NOT mark payloads with deliverDespiteSourceReplySuppression or use skipSuppressionCheck. The message_tool_only privacy contract is fully preserved: delivery only happens when the agent explicitly calls message(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 existing shouldWarnAboutPrivateMessageToolFinal guard.

Why followup retry instead of bypass delivery?

The message_tool_only contract 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

Suite Count Status
agent-runner.misc.runreplyagent.test.ts 47 ✅ All pass
private-message-tool-final.test.ts 8 ✅ All pass
dispatch-from-config.test.ts 189 ✅ All pass (unchanged, confirms no regressions)
followup-runner.test.ts 67 ✅ All pass (confirms followup queue compatibility)

New test cases:

  • enqueues a followup retry for a stranded substantive reply (#85714) — verifies enqueueFollowupRun is called with retry prompt containing the assistant text
  • does not enqueue retry for short private final replies — negative case
  • does not enqueue retry when the message tool delivered this turn — negative case
  • does not enqueue retry for room_event turns (#85714) — verifies room_event gate
  • clears transcriptPrompt on retry to prevent original prompt from hiding retry instruction (#85714) — regression test verifying transcriptPrompt, userTurnTranscriptRecorder, and currentInboundContext are all undefined on the retry run

Real behavior proof

  • Behavior or issue addressed: Agent's final message silently discarded when LLM forgets to call the message tool in message_tool_only mode. The fix enqueues a followup retry turn so the agent can deliver via message(action=send), preserving the privacy contract.
  • Real environment tested: Local macOS arm64, Node.js v24.10.0, patched checkout at 9dc3c76dc3.
  • Privacy contract preserved: The fix does NOT use deliverDespiteSourceReplySuppression or skipSuppressionCheck. No payload is auto-delivered. The agent is re-prompted to call message(action=send) itself.
  • Transcript isolation: The retry run clears transcriptPrompt, userTurnTranscriptRecorder, and currentInboundContext so the followup runner's transcriptPrompt ?? extracted.text resolution correctly uses the retry prompt, not the original user message.
  • Room event safety: Stranded reply detection is gated by sessionCtx.InboundEventKind !== "room_event", so ambient room events (where silence is intentional) never trigger recovery. Covered by dedicated test case.
  • Failing proof before fix: On upstream/main, warnPrivateMessageToolFinal only logs — no retry is enqueued and the reply is silently lost.
  • Evidence after fix:
    ✓ auto-reply agent-runner.misc.runreplyagent.test.ts (47 tests) 1729ms
    Test Files  1 passed (1)
         Tests  47 passed (47)
    
    ✓ unit-fast private-message-tool-final.test.ts (8 tests) 2ms
    Test Files  1 passed (1)
         Tests  8 passed (8)
    
    ✓ auto-reply dispatch-from-config.test.ts (189 tests) 3770ms
    Test Files  1 passed (1)
         Tests  189 passed (189)
    
    ✓ auto-reply followup-runner.test.ts (67 tests) 1710ms
    Test Files  1 passed (1)
         Tests  67 passed (67)
    
  • Observed result after fix: When a stranded reply is detected, enqueueFollowupRun is called with a retry prompt containing the original assistant text and instructions to call message(action=send). The retry run has transcriptPrompt: undefined so 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.
  • What was not tested: Live end-to-end delivery through an actual channel — the test verifies that the correct followup run is enqueued with the expected prompt shape and cleaned fields. The followup queue machinery (enqueueFollowupRunscheduleFollowupDrainrunReplyAgent) 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 stub enqueueFollowupRun/runEmbeddedAgent).

  • Behavior or issue addressed: Under messages.visibleReplies=message_tool (resolved message_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 via message(action=send), preserving the privacy contract; if the retry strands again, no second retry is enqueued.
  • Real environment tested: macOS Darwin 24.3.0 arm64, Node.js v24.10.0, branch fix/stranded-reply-pending-delivery-85714 at current HEAD 0469009210 (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-aware mock-openai provider (deterministic by construction; no live model, no Docker).
  • Exact steps or command run after this patch: ran the new scenario, PASS:
    node scripts/run-node.mjs qa suite \
      --provider-mode mock-openai \
      --scenario message-tool-stranded-final-recovery \
      --concurrency 1 --fast
    
  • Evidence after fix: captured scenario result and the gateway's own runtime log line (ids redacted):
    COUNTS: { total: 1, passed: 1, failed: 0 }
    step: pass - recovered=1; stranded turns=1; retry delivery turns=1; WARN logged=true
    
    2026-06-06T06:31:23.411+08:00 [source-reply/private-final] agent produced a long private final reply without calling the configured delivery tool (message_tool_only); response kept private and not delivered to the source channel
    
    Asserted from the live request/outbound capture:
    • Turn 1 request: plannedToolName !== "message" (long plain-text final, stranded).
    • Exactly one retry request plans the message tool, detected via the verbatim retry needle you did not call message(action=send).
    • Retry delivery request: plannedToolName === "message", plannedToolArgs.action === "send", plannedToolArgs.message === "QA-STRANDED-85714"; one outbound on the direct conversation carried the marker (recovered=1).
    • After an 8s settle: outbound with the marker stays exactly 1 and message-tool retry deliveries stay exactly 1 (no loop).
  • Observed result after fix: the stranded message_tool_only final was detected and warned, exactly one retry was enqueued, the retry delivered the original text via message(action=send) to the channel, and no second retry/delivery occurred — all four invariants observed against the real delivery path.
  • What was not tested: not run against a live frontier model (deterministic mock by design, since a real model "forgetting" the tool is non-deterministic), and not on a real chat transport (Telegram/WhatsApp/Discord) — exercised on the in-process qa-channel bus; the 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) and extensions/qa-lab/src/providers/mock-openai/server.ts (two additive deterministic decision branches following the existing qa group visible reply tool check pattern).

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=false and routes the drain through scheduleFollowupDrainAfterReplyOperationClear (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.

  • Changed: src/auto-reply/reply/agent-runner.ts — stranded retry enqueue mirrors the normal enqueue-followup path (dormant enqueue + deferred drain).
  • Test: src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts — added schedules 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.
  • Verification after this change (local, macOS arm64, Node v24.10.0):
    vitest run src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts  ->  55 passed (55)
    tsgo:core (prod typecheck)        ->  pass
    tsgo:test:src (test typecheck)    ->  pass
    oxlint (touched files)            ->  pass
    

@openclaw-barnacle openclaw-barnacle Bot added size: S proof: supplied External PR includes structured after-fix real behavior proof. labels Jun 1, 2026
@clawsweeper

clawsweeper Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Codex review: needs real behavior proof before merge. Reviewed July 9, 2026, 12:21 PM ET / 16:21 UTC.

Summary
The branch adds a default-off messages.strandedReplyRecovery config, stronger message-tool prompt wording, a one-shot sanitized follow-up retry for stranded message_tool_only finals, queue isolation, docs, and unit plus QA-lab coverage.

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 message_tool_only final with no message-tool send and then suppresses pending delivery; the PR's mock QA scenario exercises that exact path.

Review metrics: 2 noteworthy metrics.

  • Config surface: 1 added, default false. messages.strandedReplyRecovery changes operator-visible delivery recovery behavior and needs compatibility/privacy review before merge.
  • Queue metadata: 2 FollowupRun fields added. disableCollectBatching and strandedReplyRetry alter follow-up drain and guard semantics that affect delivery ordering and retry behavior.

Root-cause cluster
Relationship: fixed_by_candidate
Canonical: #85714
Summary: This PR is a candidate fix for the canonical stranded message_tool_only final report; the newer alternative overlaps but is currently conflicting and not a safe superseding close target.

Members:

Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything.

Merge readiness
Overall: 🦐 gold shrimp
Proof: 🦐 gold shrimp
Patch quality: 🐚 platinum hermit
Result: blocked until stronger real behavior proof is added.

Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch.

Rank-up moves:

  • [P1] Add redacted after-fix proof from a real Telegram, Discord, Teams, WhatsApp, or similar run showing one recovered delivery and no duplicate.
  • [P1] Get explicit maintainer acceptance for the default-off recovery/privacy behavior before merge.

Proof guidance:

  • [P1] Needs stronger real behavior proof before merge: The PR body supplies unit output and deterministic QA-lab/mock-openai terminal proof, but not after-fix proof from a real external chat transport; add redacted live logs, terminal output, screenshots, or a recording and update the PR body to trigger re-review.

Mantis proof suggestion
A Telegram Desktop transcript would materially help prove the visible chat delivery path that unit and qa-channel mock proof cannot cover. A maintainer can ask Mantis to capture proof by posting this exact PR comment:

@openclaw-mantis telegram desktop proof: verify that an opted-in message_tool_only stranded final enqueues one retry and visibly delivers via message(action=send) without a duplicate.

Risk before merge

  • [P1] Merging adds a new operator-visible messages.strandedReplyRecovery config whose opt-in behavior replays normalized private final text into a synthetic retry prompt.
  • [P1] The supplied after-fix proof is deterministic unit/QA-lab/mock-openai terminal proof, not a real external Telegram, Discord, Teams, WhatsApp, or similar chat transport run.
  • [P1] Message-delivery safety depends on exactly-one retry, directive normalization, persistence suppression, and collect-queue isolation; targeted tests cover these invariants, but live channel boundaries remain unproven.

Maintainer options:

  1. Require live channel proof and signoff (recommended)
    Ask for redacted after-fix proof from a real external chat transport plus explicit maintainer acceptance of the default-off privacy contract before merge.
  2. Accept the opt-in boundary deliberately
    Maintainers may merge after owning the risk that normalized private final text is sent back through a synthetic retry prompt when the flag is enabled.
  3. Pause for policy choice
    If maintainers prefer the conflicting default-on alternative or strict tool-only semantics, keep this branch paused rather than landing a second recovery policy.

Next step before merge

  • [P1] Manual review is needed because the blockers are maintainer product/privacy acceptance and real external behavior proof, not a narrow automated repair.

Maintainer decision needed

  • Question: Should OpenClaw accept a default-off messages.strandedReplyRecovery mode that replays normalized private message_tool_only final text into one synthetic retry prompt, or keep the strict no-message-call contract and pursue a different recovery policy?
  • Rationale: The code path is reviewable, but automation cannot choose the product and privacy contract for opt-in recovery of private final text across channels.
  • Likely owner: steipete — The decision is about the core delivery/privacy boundary, and the current history points to steipete as the strongest recent route for this area.
  • Options:
    • Accept opt-in recovery after proof (recommended): Keep the default false, require redacted real external channel proof, and let operators opt into one-shot recovery when they prefer recovery over strict silence.
    • Keep strict tool-only semantics: Reject the recovery surface and rely on stronger prompts, stronger models, or automatic visible replies for operators who cannot tolerate silence.
    • Choose a different recovery policy: Use the linked issue to sponsor a different core or channel-owned policy, such as the default-on but currently conflicting alternative PR.

Security
Cleared: No concrete supply-chain, dependency, secret, permission, or code-execution regression was found; the privacy-sensitive replay behavior is tracked as merge risk and maintainer decision.

Review details

Best 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 message(action=send) without duplication or private-prompt persistence.

Do we have a high-confidence way to reproduce the issue?

Yes. Source inspection shows current main warns for a substantive message_tool_only final with no message-tool send and then suppresses pending delivery; the PR's mock QA scenario exercises that exact path.

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 changes

Label changes:

  • add rating: 🦐 gold shrimp: Overall readiness is 🦐 gold shrimp; proof is 🦐 gold shrimp and patch quality is 🐚 platinum hermit.
  • remove rating: 🦪 silver shellfish: Current PR rating is rating: 🦐 gold shrimp, so this older rating label is no longer current.

Label justifications:

  • P1: The PR targets silent message loss in an agent/channel workflow, and linked discussion includes recurring production reports.
  • merge-risk: 🚨 compatibility: The PR adds a new config/default surface and opt-in behavior for setups that currently rely on strict message_tool_only privacy semantics.
  • merge-risk: 🚨 message-delivery: The patch changes source-channel retry delivery, follow-up queue draining, and no-loop behavior that can affect dropped, duplicated, or delayed replies.
  • merge-risk: 🚨 security-boundary: The recovery path sits on the privacy boundary between private assistant finals, retry prompts sent back to the model, and user-visible channel delivery.
  • rating: 🦐 gold shrimp: Overall readiness is 🦐 gold shrimp; proof is 🦐 gold shrimp and patch quality is 🐚 platinum hermit.
  • status: 📣 needs proof: The PR needs real behavior proof before ClawSweeper can clear the contributor ask. Needs stronger real behavior proof before merge: The PR body supplies unit output and deterministic QA-lab/mock-openai terminal proof, but not after-fix proof from a real external chat transport; add redacted live logs, terminal output, screenshots, or a recording and update the PR body to trigger re-review.
  • mantis: telegram-visible-proof: Mantis should capture Telegram visible proof. The generic core change affects Telegram-visible message-tool-only delivery and can be demonstrated in a short Telegram Desktop recovery recording.
Evidence reviewed

PR surface:

Source +159, Tests +282, Docs +2, Generated 0, Other +144. Total +587 across 17 files.

View PR surface stats
Area Files Added Removed Net
Source 9 164 5 +159
Tests 5 295 13 +282
Docs 1 3 1 +2
Config 0 0 0 0
Generated 1 2 2 0
Other 1 144 0 +144
Total 17 608 21 +587

What I checked:

  • Current main remains warning-only: Current main detects substantive private message_tool_only finals and logs warnPrivateMessageToolFinal, then keeps pending delivery empty when source delivery is suppressed; no retry or fallback delivery exists on main. (src/auto-reply/reply/agent-runner.ts:2578, 148ec3282f07)
  • PR recovery path: The PR gates room events, requires messages.strandedReplyRecovery === true, normalizes directive-laden final text, enqueues a private strandedReplyRetry follow-up, suppresses retry prompt persistence, and defers drain until the active reply operation clears. (src/auto-reply/reply/agent-runner.ts:2588, bc5a7d9fb1e1)
  • Queue isolation: The PR adds disableCollectBatching and strandedReplyRetry metadata and makes collect-mode drains process those items individually so the retry prompt and private guard are not folded into a batch. (src/auto-reply/reply/queue/drain.ts:390, bc5a7d9fb1e1)
  • Config and docs surface: The PR adds messages.strandedReplyRecovery?: boolean, documents default false, and explains that a successful retry persists normally through the message-tool transcript path. (src/config/types.messages.ts:124, bc5a7d9fb1e1)
  • QA-lab proof is deterministic but mocked: The added QA scenario enables the new flag, uses mock-openai, verifies one message-tool retry delivery, and verifies no loop; this is useful terminal proof but not a real Telegram/Discord/Teams/WhatsApp transport run. (qa/scenarios/channels/message-tool-stranded-final-recovery.yaml:12, bc5a7d9fb1e1)
  • Superseding candidate is not safe canonical target: Live GitHub reports Recover stranded message-tool finals by default #99536 as open but mergeable=CONFLICTING and mergeStateStatus=DIRTY, so it cannot safely supersede this PR for cleanup closure. (7b2eefe38678)

Likely related people:

  • yaoyi1222: Authored the merged warning-only PR that introduced the private-final warning helper and current warning branch for this failure class. (role: diagnostics PR author; confidence: high; commits: 75e0053cf969; files: src/auto-reply/reply/private-message-tool-final.ts, src/auto-reply/reply/agent-runner.ts)
  • steipete: Merged the warning-only PR and current blame also points recent edits in the affected auto-reply path to Peter Steinberger. (role: merger and recent area contributor; confidence: high; commits: 75e0053cf969, 2f013382a0c1; files: src/auto-reply/reply/private-message-tool-final.ts, src/auto-reply/reply/agent-runner.ts)
  • Chunyue Wang: Recent history touches adjacent agent prompt/config surfaces that this PR also updates, but the central recovery path is owned more strongly by the auto-reply history above. (role: recent adjacent contributor; confidence: low; commits: f6c16d22b478; files: src/agents/system-prompt.ts, src/config/types.messages.ts, docs/gateway/config-channels.md)
What the crustacean ranks mean
  • 🦀 challenger crab: rare, exceptional readiness with strong proof, clean implementation, and convincing validation.
  • 🦞 diamond lobster: very strong readiness with only minor maintainer review expected.
  • 🐚 platinum hermit: good normal PR, likely mergeable with ordinary maintainer review.
  • 🦐 gold shrimp: useful signal, but proof or patch confidence is still limited.
  • 🦪 silver shellfish: thin signal; proof, validation, or implementation needs work.
  • 🧂 unranked krab: not merge-ready because proof is missing/unusable or there are serious correctness or safety concerns.
  • 🌊 off-meta tidepool: rating does not apply to this item.

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
  • ClawSweeper keeps one durable marker-backed review comment per issue or PR.
  • Re-runs edit this comment so the latest verdict, findings, and automation markers stay together instead of adding duplicate bot comments.
  • A fresh review can be triggered by eligible @clawsweeper re-review comments, exact-item GitHub events, scheduled/background review runs, or manual workflow dispatch.
  • PR/issue authors and users with repository write access can comment @clawsweeper re-review or @clawsweeper re-run on an open PR or issue to request a fresh review only.
  • Maintainers can also comment @clawsweeper review to request a fresh review only.
  • Fresh-review commands do not start repair, autofix, rebase, CI repair, or automerge.
  • Maintainer-only repair and merge flows require explicit commands such as @clawsweeper autofix, @clawsweeper automerge, @clawsweeper fix ci, or @clawsweeper address review.
  • Maintainers can comment @clawsweeper explain to ask for more context, or @clawsweeper stop to stop active automation.
Review history (30 earlier review cycles; latest 8 shown)
  • reviewed 2026-07-08T07:27:15.613Z sha 1083cc4 :: needs real behavior proof before merge. :: none
  • reviewed 2026-07-08T13:25:12.801Z sha 37435a0 :: needs real behavior proof before merge. :: none
  • reviewed 2026-07-08T13:41:59.204Z sha 37435a0 :: needs real behavior proof before merge. :: none
  • reviewed 2026-07-08T16:25:59.963Z sha ac224e3 :: needs real behavior proof before merge. :: none
  • reviewed 2026-07-08T16:37:34.948Z sha ac224e3 :: needs real behavior proof before merge. :: none
  • reviewed 2026-07-08T22:22:48.331Z sha 2052013 :: needs real behavior proof before merge. :: none
  • reviewed 2026-07-09T04:13:28.162Z sha e2c11de :: needs real behavior proof before merge. :: none
  • reviewed 2026-07-09T10:14:01.114Z sha 806913d :: needs real behavior proof before merge. :: none

@clawsweeper clawsweeper Bot added rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. mantis: telegram-visible-proof Mantis should capture Telegram visible proof. P1 High-priority user-facing bug, regression, or broken workflow. merge-risk: 🚨 compatibility 🚨 May break existing users, config, migrations, defaults, or upgrade paths. merge-risk: 🚨 message-delivery 🚨 May drop, duplicate, misroute, suppress, or wrongly target messages. merge-risk: 🚨 security-boundary 🚨 May affect sandboxing, authorization, credentials, or sensitive data. labels Jun 1, 2026
@Jerry-Xin
Jerry-Xin force-pushed the fix/stranded-reply-pending-delivery-85714 branch 2 times, most recently from 145458b to 4f36338 Compare June 1, 2026 18:07
@Jerry-Xin

Copy link
Copy Markdown
Contributor Author

Rebased on latest upstream/main (965e680, clean rebase, no conflicts).

@Jerry-Xin
Jerry-Xin force-pushed the fix/stranded-reply-pending-delivery-85714 branch from 4f36338 to 45a163a Compare June 1, 2026 22:06
@Jerry-Xin

Copy link
Copy Markdown
Contributor Author

Rebased on latest upstream/main (646df2d, clean rebase, no conflicts).

@Jerry-Xin
Jerry-Xin force-pushed the fix/stranded-reply-pending-delivery-85714 branch from 45a163a to 84a9b08 Compare June 2, 2026 00:06
@Jerry-Xin

Copy link
Copy Markdown
Contributor Author

Rebased on latest upstream/main (8a9acd2, clean rebase, no conflicts).

@Jerry-Xin
Jerry-Xin force-pushed the fix/stranded-reply-pending-delivery-85714 branch from 84a9b08 to 0ea5a6c Compare June 2, 2026 02:06
@Jerry-Xin

Copy link
Copy Markdown
Contributor Author

Rebased on latest upstream/main (57ea5af, clean rebase, no conflicts).

@Jerry-Xin
Jerry-Xin force-pushed the fix/stranded-reply-pending-delivery-85714 branch from 0ea5a6c to d3aa6b6 Compare June 2, 2026 04:06
@Jerry-Xin

Copy link
Copy Markdown
Contributor Author

Rebased on latest upstream/main (ffbd02f, clean rebase, no conflicts).

@Jerry-Xin
Jerry-Xin force-pushed the fix/stranded-reply-pending-delivery-85714 branch from d3aa6b6 to b76e9fc Compare June 2, 2026 06:06
@Jerry-Xin

Copy link
Copy Markdown
Contributor Author

Rebased on latest upstream/main (85d2dd8, clean rebase, no conflicts).

@Jerry-Xin
Jerry-Xin force-pushed the fix/stranded-reply-pending-delivery-85714 branch from 76e92dc to 82aad34 Compare June 2, 2026 07:31
@Jerry-Xin

Copy link
Copy Markdown
Contributor Author

@clawsweeper re-review

@clawsweeper

clawsweeper Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

🦞🧹
ClawSweeper re-review requested.

I asked ClawSweeper to review this item again.
Action: item re-review queued (workflow sweep.yml, event repository_dispatch).
Result: the existing ClawSweeper review comment will be edited in place when the review finishes.

Re-review progress:

@Jerry-Xin
Jerry-Xin force-pushed the fix/stranded-reply-pending-delivery-85714 branch from 82aad34 to 85c566e Compare June 2, 2026 09:10
@Jerry-Xin

Copy link
Copy Markdown
Contributor Author

@clawsweeper re-review

Rewrote the approach: replaced suppression-bypass delivery with followup retry. The agent now gets a second chance to call message(action=send) itself, fully preserving the message_tool_only privacy contract. Also added room_event gate to skip stranded detection for ambient events. No deliverDespiteSourceReplySuppression, no skipSuppressionCheck.

@clawsweeper

clawsweeper Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

🦞🧹
ClawSweeper re-review requested.

I asked ClawSweeper to review this item again.
Action: item re-review queued (workflow sweep.yml, event repository_dispatch).
Result: the existing ClawSweeper review comment will be edited in place when the review finishes.

Re-review progress:

@Jerry-Xin

Copy link
Copy Markdown
Contributor Author

Rebased on latest upstream/main (827f2c4, clean rebase, no conflicts). @clawsweeper re-review

@Jerry-Xin

Copy link
Copy Markdown
Contributor Author

Rebased on latest upstream/main (7e03242, clean rebase, no conflicts). @clawsweeper re-review

@Jerry-Xin

Copy link
Copy Markdown
Contributor Author

Rebased on latest upstream/main (e25fa79, clean rebase, no conflicts). @clawsweeper re-review

@Jerry-Xin

Copy link
Copy Markdown
Contributor Author

Rebased on latest upstream/main (534ace4, clean rebase, no conflicts). @clawsweeper re-review

@Jerry-Xin

Copy link
Copy Markdown
Contributor Author

Rebased on latest upstream/main (45d0970, clean rebase, no conflicts). @clawsweeper re-review

@Jerry-Xin

Copy link
Copy Markdown
Contributor Author

Rebased on latest upstream/main (6e4670f, clean rebase, no conflicts). @clawsweeper re-review

@Jerry-Xin

Copy link
Copy Markdown
Contributor Author

Rebased on latest upstream/main (fae5421, clean rebase, no conflicts). @clawsweeper re-review

@Jerry-Xin

Copy link
Copy Markdown
Contributor Author

Rebased on latest upstream/main (4f9a371, clean rebase, no conflicts). @clawsweeper re-review

忻役 and others added 19 commits July 10, 2026 06:05
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.
@Jerry-Xin

Copy link
Copy Markdown
Contributor Author

Rebased on latest upstream/main (2f057e1, clean rebase, no conflicts). @clawsweeper re-review

@obviyus

obviyus commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

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 messages.strandedReplyRecovery config surface proposed here is no longer needed — the repo's config bar prefers product behavior over new keys when possible.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling docs Improvements or additions to documentation extensions: qa-lab gateway Gateway runtime mantis: telegram-visible-proof Mantis should capture Telegram visible proof. merge-risk: 🚨 compatibility 🚨 May break existing users, config, migrations, defaults, or upgrade paths. merge-risk: 🚨 message-delivery 🚨 May drop, duplicate, misroute, suppress, or wrongly target messages. merge-risk: 🚨 security-boundary 🚨 May affect sandboxing, authorization, credentials, or sensitive data. P1 High-priority user-facing bug, regression, or broken workflow. proof: supplied External PR includes structured after-fix real behavior proof. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. size: L status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Agent's final agent_message stranded when LLM forgets to call configured delivery tool (no fallback delivery from task_complete)

5 participants