Skip to content

buildExecEventPrompt: 'user delivery disabled' branch races runtime silent-reply detection, causes incomplete-turn errors #75322

Description

@mbrya859-byte

Summary

When buildExecEventPrompt runs with deliverToUser=false, the system message asks the agent to "reply HEARTBEAT_OK only." Capable models that have learned OpenClaw's silent-reply convention often substitute the runtime's actual silent-reply sentinel NO_REPLY — but emit it without <final>...</final> wrapping, since the prompt implies no user-visible answer is needed. The runtime then sees payloads=0, classifies the turn as an empty response, retries with the visible-answer continuation, exhausts retries, and surfaces an incomplete turn detected error to the user channel — producing exactly the kind of noise async-completion suppression is meant to avoid.

Reproducible signal

Gateway version: 2026.4.24 (cbcfdf6)
Agent: arya running google/gemini-2.5-flash-lite (with gemini-2.5-flash fallback)

Journal pattern (recurring on every async-completion event today):

[agent/embedded] empty response detected: runId=... sessionId=...
                  provider=google/gemini-2.5-flash-lite
                  — retrying 1/1 with visible-answer continuation
[agent/embedded] empty response retries exhausted: ... attempts=1/1
                  — surfacing incomplete-turn error
[agent/embedded] incomplete turn detected: ... stopReason=stop payloads=0
                  — surfacing error to user

Trace

Session JSONL (agents/<agent>/sessions/<sessionId>.jsonl) shows the full sequence:

  1. Turn N (good): User says "run openclaw doctor". Agent emits <final>The openclaw doctor command is running...</final>payloads=1, runtime accepts, delivers to Discord. ✓

  2. Turn N+1 (system message): Runtime sends:

    System (untrusted): [timestamp] Exec completed (..., code 0) :: ... An async command completion event was triggered, but user delivery is disabled for this run. Handle the result internally and reply HEARTBEAT_OK only. Do not mention, summarize, or reuse command output.

  3. Turn N+1 (model's reply): Model generates plain NO_REPLY (no <final> tags). Stop reason stop, output text "NO_REPLY", but payloads=0 because no <final> block was emitted.

  4. Runtime retries with continuation prompt: "The previous attempt did not produce a user-visible answer. Continue from the current state and produce the visible answer now. Do not restart from scratch."

  5. Turn N+1 retry: Model generates NO_REPLY again, same shape.

  6. Retries exhaustedincomplete-turn error surfaces to user. Cosmetic noise on the user-facing channel even though deliverToUser=false.

Root cause

In dist/heartbeat-events-filter-DZ-HBajV.js:

function buildExecEventPrompt(pendingEvents, opts) {
    const deliverToUser = opts?.deliverToUser ?? true;
    // ...
    if (!eventText) return "An async command completion event was triggered, but no command output was found. Reply HEARTBEAT_OK only. Do not mention, summarize, or reuse output from any earlier run.";
    if (!deliverToUser) return "An async command completion event was triggered, but user delivery is disabled for this run. Handle the result internally and reply HEARTBEAT_OK only. Do not mention, summarize, or reuse command output.";
    // ...
}

The prompt has two issues working together:

  1. It uses HEARTBEAT_OK (a noise-filter token, not the runtime's silent-reply sentinel). Models that know about NO_REPLY (the actual silent token defined in dist/tokens-C_v_J0E7.js as SILENT_REPLY_TOKEN = "NO_REPLY") will often prefer it because the prompt's phrasing — "user delivery is disabled," "handle the result internally" — semantically matches the silent-reply concept much more than it matches the noise-filter concept.

  2. It does not specify the <final>...</final> wrapping requirement. With "user delivery is disabled," the model reasonably concludes that user-visible wrapping is unnecessary. But the runtime's empty-response detector keys off the <final> block, so unwrapped tokens (including NO_REPLY) trip payloads=0.

Suggested fix

Change the !deliverToUser branch (and ideally the empty-eventText branch) to point at the actual silent-reply sentinel and demonstrate the required wrapping:

- if (!eventText) return "An async command completion event was triggered, but no command output was found. Reply HEARTBEAT_OK only. Do not mention, summarize, or reuse output from any earlier run.";
- if (!deliverToUser) return "An async command completion event was triggered, but user delivery is disabled for this run. Handle the result internally and reply HEARTBEAT_OK only. Do not mention, summarize, or reuse command output.";
+ if (!eventText) return "An async command completion event was triggered, but no command output was found. Reply with exactly `<final>NO_REPLY</final>` and nothing else. Do not mention, summarize, or reuse output from any earlier run.";
+ if (!deliverToUser) return "An async command completion event was triggered, but user delivery is disabled for this run. Reply with exactly `<final>NO_REPLY</final>` and nothing else. Do not mention, summarize, or reuse command output.";

This:

  • Uses the runtime's actual silent-reply sentinel — guaranteed recognized by dist/dispatch-Dw_9PM8V.js and dist/normalize-reply-BIXTOBH2.js
  • Explicitly demonstrates the <final> wrapping the runtime requires
  • Removes the dual-token ambiguity (no more HEARTBEAT_OK vs NO_REPLY decision for the model)
  • Keeps the noise-suppression intent intact

Severity

Cosmetic but persistent. Each affected agent surfaces an incomplete turn error to its channel on every async-completion event — for a heartbeat-style agent that runs openclaw doctor or similar every 15 minutes, that's ~96 false errors/day, which defeats the suppression mechanism's purpose.

Workaround applied locally

Updated agent's HEARTBEAT.md and IDENTITY.md to explicitly require <final>...</final> wrapping on every reply and to forbid bare NO_REPLY. This catches agents that read their own prompt files; it doesn't help fresh agents or agents whose system messages come purely from runtime-generated content.

Metadata

Metadata

Assignees

No one assigned

    Labels

    staleMarked as stale due to inactivity

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions