Skip to content

Commit daef8e7

Browse files
committed
fix: prune replay control messages
1 parent c7dcf79 commit daef8e7

13 files changed

Lines changed: 279 additions & 40 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
1616
- Agents/media: preserve message-tool-only delivery for generated music and video completion handoffs, so group/channel completions do not finish without posting the generated attachment.
1717
- Agents: strip Gemini/Gemma `<final>` tags with attributes or self-closing syntax from delivered replies, including strict final-tag streaming enforcement. Fixes #65867.
1818
- macOS/update: disarm legacy `ai.openclaw.update.*` LaunchAgents when `openclaw update` starts from one, preventing KeepAlive relaunch loops that repeatedly restart the Gateway and replay update continuations. Fixes #82167.
19+
- Agents/replay: strip internal runtime-context metadata and `NO_REPLY` sentinels from provider replay and pending final-delivery recovery so restart and heartbeat resumes do not feed control text back to the model. Fixes #76629. Thanks @fuyizheng3120, @bryan-chx, and @cael-dandelion-cult.
1920
- LINE: acknowledge signed webhook events before agent processing so slow model replies do not cause LINE `request_timeout` delivery failures. Fixes #65375. Thanks @myericho.
2021
- LINE: stop cron recovery from inferring lowercased LINE recipients from canonical session keys, so long-running task replies do not silently retry undeliverable push targets. Fixes #81628. (#81704) Thanks @edenfunf.
2122
- TTS: preserve channel-derived voice-note delivery for `/tts audio` replies even when the provider output is not natively voice-compatible. (#82174) Thanks @xuruiray.

src/agents/agent-command.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { sanitizePendingFinalDeliveryText } from "../auto-reply/reply/pending-final-delivery.js";
12
import {
23
formatThinkingLevels,
34
isThinkingLevelSupported,
@@ -1323,10 +1324,12 @@ async function agentCommandInternal(
13231324
!isSubagentSessionKey(sessionKey)
13241325
) {
13251326
const now = Date.now();
1326-
const combinedPayload = payloads
1327-
.map((p) => (typeof p.text === "string" ? p.text : ""))
1328-
.filter(Boolean)
1329-
.join("\n\n");
1327+
const combinedPayload = sanitizePendingFinalDeliveryText(
1328+
payloads
1329+
.map((p) => (typeof p.text === "string" ? p.text : ""))
1330+
.filter(Boolean)
1331+
.join("\n\n"),
1332+
);
13301333

13311334
if (combinedPayload) {
13321335
const entry = sessionStore[sessionKey] ?? sessionEntry;

src/agents/main-session-restart-recovery.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import path from "node:path";
44
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
55
import { loadSessionStore, type SessionEntry } from "../config/sessions.js";
66
import { callGateway } from "../gateway/call.js";
7+
import {
8+
INTERNAL_RUNTIME_CONTEXT_BEGIN,
9+
INTERNAL_RUNTIME_CONTEXT_END,
10+
} from "./internal-runtime-context.js";
711
import {
812
markRestartAbortedMainSessionsFromLocks,
913
recoverRestartAbortedMainSessions,
@@ -330,6 +334,47 @@ describe("main-session-restart-recovery", () => {
330334
);
331335
});
332336

337+
it("sanitizes durable pending final delivery payloads before resume prompts", async () => {
338+
const sessionsDir = await makeSessionsDir();
339+
const pendingPayload = [
340+
"The final answer is 42.",
341+
INTERNAL_RUNTIME_CONTEXT_BEGIN,
342+
"internal recovery detail",
343+
INTERNAL_RUNTIME_CONTEXT_END,
344+
"",
345+
"Conversation info (untrusted metadata):",
346+
"```json",
347+
'{"message_id":"msg-1"}',
348+
"```",
349+
].join("\n");
350+
await writeStore(sessionsDir, {
351+
"agent:main:main": {
352+
sessionId: "main-session",
353+
updatedAt: Date.now() - 10_000,
354+
status: "running",
355+
abortedLastRun: true,
356+
pendingFinalDelivery: true,
357+
pendingFinalDeliveryText: pendingPayload,
358+
pendingFinalDeliveryCreatedAt: Date.now() - 5_000,
359+
},
360+
});
361+
await writeTranscript(sessionsDir, "main-session", [
362+
{ role: "user", content: "calculate the answer" },
363+
{ role: "assistant", content: [{ type: "toolCall", id: "call-1", name: "calc" }] },
364+
{ role: "toolResult", content: "42" },
365+
]);
366+
367+
const result = await recoverRestartAbortedMainSessions({ stateDir: tmpDir });
368+
369+
expect(result).toEqual({ recovered: 1, failed: 0, skipped: 0 });
370+
expect(firstGatewayParams().message).toContain("The final answer is 42.");
371+
expect(firstGatewayParams().message).not.toContain(INTERNAL_RUNTIME_CONTEXT_BEGIN);
372+
expect(firstGatewayParams().message).not.toContain("Conversation info");
373+
374+
const store = loadSessionStore(path.join(sessionsDir, "sessions.json"));
375+
expect(store["agent:main:main"]?.pendingFinalDeliveryText).toBe("The final answer is 42.");
376+
});
377+
333378
it("does not scan ordinary running sessions without the restart-aborted marker", async () => {
334379
const sessionsDir = await makeSessionsDir();
335380
await writeStore(sessionsDir, {

src/agents/main-session-restart-recovery.ts

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import crypto from "node:crypto";
66
import fs from "node:fs";
77
import path from "node:path";
8+
import { sanitizePendingFinalDeliveryText } from "../auto-reply/reply/pending-final-delivery.js";
89
import { resolveStateDir } from "../config/paths.js";
910
import {
1011
type SessionEntry,
@@ -121,8 +122,12 @@ function buildResumeMessage(pendingFinalDeliveryText?: string | null): string {
121122
"[System] Your previous turn was interrupted by a gateway restart while " +
122123
"OpenClaw was waiting on tool/model work. Continue from the existing " +
123124
"transcript and finish the interrupted response.";
124-
if (pendingFinalDeliveryText) {
125-
return `${base}\n\nNote: The interrupted final reply was captured: "${pendingFinalDeliveryText}"`;
125+
const sanitizedPendingText =
126+
typeof pendingFinalDeliveryText === "string"
127+
? sanitizePendingFinalDeliveryText(pendingFinalDeliveryText)
128+
: "";
129+
if (sanitizedPendingText) {
130+
return `${base}\n\nNote: The interrupted final reply was captured: "${sanitizedPendingText}"`;
126131
}
127132
return base;
128133
}
@@ -162,11 +167,15 @@ async function resumeMainSession(params: {
162167
sessionKey: string;
163168
pendingFinalDeliveryText?: string | null;
164169
}): Promise<boolean> {
170+
const sanitizedPendingText =
171+
typeof params.pendingFinalDeliveryText === "string"
172+
? sanitizePendingFinalDeliveryText(params.pendingFinalDeliveryText)
173+
: "";
165174
try {
166175
await callGateway<{ runId: string }>({
167176
method: "agent",
168177
params: {
169-
message: buildResumeMessage(params.pendingFinalDeliveryText),
178+
message: buildResumeMessage(sanitizedPendingText),
170179
sessionKey: params.sessionKey,
171180
idempotencyKey: crypto.randomUUID(),
172181
deliver: false,
@@ -185,18 +194,29 @@ async function resumeMainSession(params: {
185194
entry.abortedLastRun = false;
186195
entry.updatedAt = now;
187196
if (entry.pendingFinalDelivery || entry.pendingFinalDeliveryText) {
188-
entry.pendingFinalDeliveryLastAttemptAt = now;
189-
entry.pendingFinalDeliveryAttemptCount =
190-
(entry.pendingFinalDeliveryAttemptCount ?? 0) + 1;
191-
entry.pendingFinalDeliveryLastError = null;
197+
if (sanitizedPendingText) {
198+
entry.pendingFinalDeliveryLastAttemptAt = now;
199+
entry.pendingFinalDeliveryAttemptCount =
200+
(entry.pendingFinalDeliveryAttemptCount ?? 0) + 1;
201+
entry.pendingFinalDeliveryLastError = null;
202+
entry.pendingFinalDeliveryText = sanitizedPendingText;
203+
} else {
204+
entry.pendingFinalDelivery = undefined;
205+
entry.pendingFinalDeliveryText = undefined;
206+
entry.pendingFinalDeliveryCreatedAt = undefined;
207+
entry.pendingFinalDeliveryLastAttemptAt = undefined;
208+
entry.pendingFinalDeliveryAttemptCount = undefined;
209+
entry.pendingFinalDeliveryLastError = undefined;
210+
entry.pendingFinalDeliveryContext = undefined;
211+
}
192212
}
193213
store[params.sessionKey] = entry;
194214
},
195215
{ skipMaintenance: true },
196216
);
197217
log.info(
198218
`resumed interrupted main session: ${params.sessionKey}${
199-
params.pendingFinalDeliveryText ? " (with pending payload)" : ""
219+
sanitizedPendingText ? " (with pending payload)" : ""
200220
}`,
201221
);
202222
return true;

src/agents/pi-embedded-runner/replay-history.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import type { AgentMessage } from "@earendil-works/pi-agent-core";
22
import { describe, expect, it } from "vitest";
3+
import {
4+
INTERNAL_RUNTIME_CONTEXT_BEGIN,
5+
INTERNAL_RUNTIME_CONTEXT_END,
6+
OPENCLAW_NEXT_TURN_RUNTIME_CONTEXT_HEADER,
7+
OPENCLAW_RUNTIME_CONTEXT_NOTICE,
8+
} from "../internal-runtime-context.js";
39
import { normalizeAssistantReplayContent } from "./replay-history.js";
410

511
const FALLBACK_TEXT = "[assistant turn failed before producing content]";
@@ -152,6 +158,36 @@ describe("normalizeAssistantReplayContent", () => {
152158
expect(JSON.stringify(out)).not.toContain("assistant copied inbound metadata omitted");
153159
});
154160

161+
it("drops standalone silent assistant replay text", () => {
162+
const messages = [userMessage("first"), bedrockAssistant("NO_REPLY"), userMessage("second")];
163+
const out = normalizeAssistantReplayContent(messages);
164+
expect(out).toEqual([messages[0], messages[2]]);
165+
});
166+
167+
it("strips copied runtime context from assistant replay text", () => {
168+
const messages = [
169+
userMessage("first"),
170+
bedrockAssistant([
171+
{
172+
type: "text",
173+
text: [
174+
"Visible before",
175+
INTERNAL_RUNTIME_CONTEXT_BEGIN,
176+
"keep this internal",
177+
INTERNAL_RUNTIME_CONTEXT_END,
178+
OPENCLAW_NEXT_TURN_RUNTIME_CONTEXT_HEADER,
179+
OPENCLAW_RUNTIME_CONTEXT_NOTICE,
180+
"",
181+
"Visible after",
182+
].join("\n"),
183+
},
184+
]),
185+
];
186+
const out = normalizeAssistantReplayContent(messages);
187+
const normalized = out[1] as AgentMessage & { content: unknown[] };
188+
expect(normalized.content).toEqual([{ type: "text", text: "Visible before\n\nVisible after" }]);
189+
});
190+
155191
it("drops metadata-only assistant text blocks without fabricating placeholder output", () => {
156192
const toolCall = { type: "toolCall", id: "call_1", name: "read", arguments: {} };
157193
const messages = [

src/agents/pi-embedded-runner/replay-history.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { AgentMessage } from "@earendil-works/pi-agent-core";
22
import type { SessionManager } from "@earendil-works/pi-coding-agent";
3-
import { stripInboundMetadata } from "../../auto-reply/reply/strip-inbound-meta.js";
3+
import { stripInternalMetadataForDisplay } from "../../auto-reply/reply/display-text-sanitize.js";
4+
import { isSilentReplyPayloadText, SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js";
45
import type { OpenClawConfig } from "../../config/types.openclaw.js";
56
import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js";
67
import {
@@ -282,8 +283,9 @@ function isTranscriptOnlyOpenclawAssistant(message: AgentMessage): boolean {
282283
}
283284

284285
function normalizeAssistantReplayTextContent(message: AgentMessage, replayContent: string) {
285-
const strippedText = stripInboundMetadata(replayContent);
286-
if (!strippedText.trim()) {
286+
const strippedText = stripInternalMetadataForDisplay(replayContent);
287+
const trimmed = strippedText.trim();
288+
if (!trimmed || isSilentReplyPayloadText(trimmed, SILENT_REPLY_TOKEN)) {
287289
return null;
288290
}
289291
return {
@@ -305,13 +307,18 @@ function normalizeAssistantReplayBlockContent(message: AgentMessage, replayConte
305307
sanitizedContent.push(block);
306308
continue;
307309
}
308-
const strippedText = stripInboundMetadata(text);
310+
const strippedText = stripInternalMetadataForDisplay(text);
309311
if (strippedText === text) {
310-
sanitizedContent.push(block);
312+
if (!isSilentReplyPayloadText(text.trim(), SILENT_REPLY_TOKEN)) {
313+
sanitizedContent.push(block);
314+
} else {
315+
touched = true;
316+
}
311317
continue;
312318
}
313319
touched = true;
314-
if (strippedText.trim()) {
320+
const trimmed = strippedText.trim();
321+
if (trimmed && !isSilentReplyPayloadText(trimmed, SILENT_REPLY_TOKEN)) {
315322
sanitizedContent.push({ ...block, text: strippedText });
316323
}
317324
}

src/auto-reply/reply/agent-runner.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ import { resolveEffectiveBlockStreamingConfig } from "./block-streaming.js";
8080
import { createFollowupRunner } from "./followup-runner.js";
8181
import { REPLY_RUN_STILL_SHUTTING_DOWN_TEXT } from "./get-reply-run-queue.js";
8282
import { resolveOriginMessageProvider, resolveOriginMessageTo } from "./origin-routing.js";
83+
import { sanitizePendingFinalDeliveryText } from "./pending-final-delivery.js";
8384
import { drainPendingToolTasks } from "./pending-tool-task-drain.js";
8485
import { readPostCompactionContext } from "./post-compaction-context.js";
8586
import { resolveActiveRunQueueAction } from "./queue-policy.js";
@@ -922,11 +923,12 @@ function joinCommitmentAssistantText(payloads: ReplyPayload[]): string {
922923
}
923924

924925
function buildPendingFinalDeliveryText(payloads: ReplyPayload[]): string {
925-
return payloads
926+
const text = payloads
926927
.filter((payload) => payload.isReasoning !== true)
927928
.map((payload) => payload.text)
928929
.filter((text): text is string => Boolean(text))
929930
.join("\n\n");
931+
return sanitizePendingFinalDeliveryText(text);
930932
}
931933

932934
function enqueueCommitmentExtractionForTurn(params: {
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { stripInternalRuntimeContext } from "../../agents/internal-runtime-context.js";
2+
import { stripEnvelope, stripMessageIdHints } from "../../shared/chat-envelope.js";
3+
import { stripInboundMetadata } from "./strip-inbound-meta.js";
4+
5+
export function stripInternalMetadataForDisplay(text: string): string {
6+
return stripInboundMetadata(stripInternalRuntimeContext(text));
7+
}
8+
9+
export function stripUserEnvelopeForDisplay(text: string): string {
10+
return stripMessageIdHints(stripEnvelope(stripInternalMetadataForDisplay(text)));
11+
}

src/auto-reply/reply/get-reply.fast-path.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ import fs from "node:fs/promises";
22
import os from "node:os";
33
import path from "node:path";
44
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
5+
import {
6+
INTERNAL_RUNTIME_CONTEXT_BEGIN,
7+
INTERNAL_RUNTIME_CONTEXT_END,
8+
} from "../../agents/internal-runtime-context.js";
59
import type { OpenClawConfig } from "../../config/config.js";
610
import {
711
buildFastReplyCommandContext,
@@ -266,6 +270,48 @@ describe("getReplyFromConfig fast test bootstrap", () => {
266270
expect(stored.pendingFinalDeliveryAttemptCount).toBe(1);
267271
});
268272

273+
it("sanitizes stale heartbeat pending delivery before replay", async () => {
274+
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-heartbeat-pending-sanitize-"));
275+
const storePath = path.join(home, "sessions.json");
276+
const sessionKey = "agent:main:telegram:123";
277+
await fs.writeFile(
278+
storePath,
279+
JSON.stringify({
280+
[sessionKey]: {
281+
sessionId: "pending-dirty-remainder",
282+
updatedAt: Date.now(),
283+
pendingFinalDelivery: true,
284+
pendingFinalDeliveryText: [
285+
"HEARTBEAT_OK",
286+
INTERNAL_RUNTIME_CONTEXT_BEGIN,
287+
"internal recovery detail",
288+
INTERNAL_RUNTIME_CONTEXT_END,
289+
"notify the user",
290+
].join("\n"),
291+
},
292+
}),
293+
"utf8",
294+
);
295+
const cfg = withFastReplyConfig({
296+
agents: {
297+
defaults: {
298+
model: "openai/gpt-5.5",
299+
workspace: home,
300+
heartbeat: { ackMaxChars: 0 },
301+
},
302+
},
303+
session: { store: storePath },
304+
} as OpenClawConfig);
305+
306+
await expect(
307+
getReplyFromConfig(buildGetReplyCtx(), { isHeartbeat: true }, cfg),
308+
).resolves.toEqual({ text: "notify the user" });
309+
310+
const stored = JSON.parse(await fs.readFile(storePath, "utf8"))[sessionKey];
311+
expect(stored.pendingFinalDeliveryText).toBe("notify the user");
312+
expect(stored.pendingFinalDeliveryAttemptCount).toBe(1);
313+
});
314+
269315
it("handles native /status before workspace bootstrap", async () => {
270316
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-native-status-fast-"));
271317
const targetSessionKey = "agent:main:telegram:123";

src/auto-reply/reply/get-reply.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import { finalizeInboundContext } from "./inbound-context.js";
4545
import { hasInboundMedia } from "./inbound-media.js";
4646
import { emitPreAgentMessageHooks } from "./message-preprocess-hooks.js";
4747
import { createFastTestModelSelectionState } from "./model-selection.js";
48+
import { sanitizePendingFinalDeliveryText } from "./pending-final-delivery.js";
4849
import { initSessionState } from "./session.js";
4950
import {
5051
isStaleHeartbeatAutoFallbackOverride,
@@ -386,7 +387,7 @@ export async function getReplyFromConfig(
386387
} = sessionState;
387388

388389
if (sessionEntry?.pendingFinalDelivery && sessionEntry.pendingFinalDeliveryText) {
389-
const text = sessionEntry.pendingFinalDeliveryText;
390+
const text = sanitizePendingFinalDeliveryText(sessionEntry.pendingFinalDeliveryText);
390391

391392
// If it's a heartbeat, we definitely want to try delivering the lost reply now.
392393
// If it's a user message, we deliver the lost reply first, then continue.
@@ -429,7 +430,8 @@ export async function getReplyFromConfig(
429430
sessionEntry.pendingFinalDeliveryLastAttemptAt = updatedAt;
430431
sessionEntry.pendingFinalDeliveryAttemptCount = attemptCount;
431432
sessionEntry.pendingFinalDeliveryLastError = null;
432-
sessionEntry.pendingFinalDeliveryText = heartbeatPending.replayText;
433+
const replayText = sanitizePendingFinalDeliveryText(heartbeatPending.replayText);
434+
sessionEntry.pendingFinalDeliveryText = replayText;
433435
sessionEntry.updatedAt = updatedAt;
434436
if (sessionKey && sessionStore) {
435437
sessionStore[sessionKey] = sessionEntry;
@@ -440,15 +442,15 @@ export async function getReplyFromConfig(
440442
storePath,
441443
sessionKey,
442444
update: async () => ({
443-
pendingFinalDeliveryText: heartbeatPending.replayText,
445+
pendingFinalDeliveryText: replayText,
444446
pendingFinalDeliveryLastAttemptAt: updatedAt,
445447
pendingFinalDeliveryAttemptCount: attemptCount,
446448
pendingFinalDeliveryLastError: null,
447449
updatedAt,
448450
}),
449451
});
450452
}
451-
return { text: heartbeatPending.replayText };
453+
return { text: replayText };
452454
}
453455
}
454456
}

0 commit comments

Comments
 (0)