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:
-
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. ✓
-
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.
-
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.
-
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."
-
Turn N+1 retry: Model generates NO_REPLY again, same shape.
-
Retries exhausted → incomplete-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:
-
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.
-
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.
Summary
When
buildExecEventPromptruns withdeliverToUser=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 sentinelNO_REPLY— but emit it without<final>...</final>wrapping, since the prompt implies no user-visible answer is needed. The runtime then seespayloads=0, classifies the turn as an empty response, retries with the visible-answer continuation, exhausts retries, and surfaces anincomplete turn detectederror 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:
aryarunninggoogle/gemini-2.5-flash-lite(withgemini-2.5-flashfallback)Journal pattern (recurring on every async-completion event today):
Trace
Session JSONL (
agents/<agent>/sessions/<sessionId>.jsonl) shows the full sequence: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. ✓Turn N+1 (system message): Runtime sends:
Turn N+1 (model's reply): Model generates plain
NO_REPLY(no<final>tags). Stop reasonstop, output text "NO_REPLY", butpayloads=0because no<final>block was emitted.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."Turn N+1 retry: Model generates
NO_REPLYagain, same shape.Retries exhausted →
incomplete-turn errorsurfaces to user. Cosmetic noise on the user-facing channel even thoughdeliverToUser=false.Root cause
In
dist/heartbeat-events-filter-DZ-HBajV.js:The prompt has two issues working together:
It uses
HEARTBEAT_OK(a noise-filter token, not the runtime's silent-reply sentinel). Models that know aboutNO_REPLY(the actual silent token defined indist/tokens-C_v_J0E7.jsasSILENT_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.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 (includingNO_REPLY) trippayloads=0.Suggested fix
Change the
!deliverToUserbranch (and ideally the empty-eventTextbranch) to point at the actual silent-reply sentinel and demonstrate the required wrapping:This:
dist/dispatch-Dw_9PM8V.jsanddist/normalize-reply-BIXTOBH2.js<final>wrapping the runtime requiresHEARTBEAT_OKvsNO_REPLYdecision for the model)Severity
Cosmetic but persistent. Each affected agent surfaces an
incomplete turnerror to its channel on every async-completion event — for a heartbeat-style agent that runsopenclaw doctoror similar every 15 minutes, that's ~96 false errors/day, which defeats the suppression mechanism's purpose.Workaround applied locally
Updated agent's
HEARTBEAT.mdandIDENTITY.mdto explicitly require<final>...</final>wrapping on every reply and to forbid bareNO_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.