Skip to content

Commit 6350ffe

Browse files
authored
Merge d5fc9d9 into 77fe36b
2 parents 77fe36b + d5fc9d9 commit 6350ffe

11 files changed

Lines changed: 713 additions & 29 deletions

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -778,6 +778,7 @@ export async function runPreparedReply(
778778
sessionKey,
779779
isMainSession,
780780
isNewSession,
781+
isHeartbeat,
781782
});
782783
if (eventsBlock) {
783784
drainedSystemEventBlocks.push(eventsBlock);

src/auto-reply/reply/queue.collect.test.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1533,3 +1533,111 @@ describe("resolveFollowupAuthorizationKey", () => {
15331533
);
15341534
});
15351535
});
1536+
1537+
describe("followup collect-mode transcript safety", () => {
1538+
it("strips internal runtime-context wrap from the transcriptPrompt of a collected batch", async () => {
1539+
const key = `test-collect-wrap-strip-${Date.now()}`;
1540+
const calls: FollowupRun[] = [];
1541+
const done = createDeferred<void>();
1542+
const runFollowup = async (run: FollowupRun) => {
1543+
calls.push(run);
1544+
done.resolve();
1545+
};
1546+
const settings: QueueSettings = {
1547+
mode: "collect",
1548+
debounceMs: 0,
1549+
cap: 50,
1550+
dropPolicy: "summarize",
1551+
};
1552+
1553+
const wrappedPrompt = [
1554+
"user message body",
1555+
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>",
1556+
"OpenClaw runtime context (internal):",
1557+
"secret cron awareness payload",
1558+
"<<<END_OPENCLAW_INTERNAL_CONTEXT>>>",
1559+
].join("\n");
1560+
const stripped = "user message body";
1561+
1562+
enqueueFollowupRun(
1563+
key,
1564+
{
1565+
...createRun({
1566+
prompt: wrappedPrompt,
1567+
transcriptPrompt: stripped,
1568+
originatingChannel: "slack",
1569+
originatingTo: "channel:A",
1570+
}),
1571+
},
1572+
settings,
1573+
);
1574+
enqueueFollowupRun(
1575+
key,
1576+
createRun({
1577+
prompt: "second message",
1578+
originatingChannel: "slack",
1579+
originatingTo: "channel:A",
1580+
}),
1581+
settings,
1582+
);
1583+
1584+
scheduleFollowupDrain(key, runFollowup);
1585+
await done.promise;
1586+
1587+
const call = calls[0];
1588+
expect(call).toBeDefined();
1589+
// Model-facing prompt keeps the wrap so runtime context still reaches the model.
1590+
expect(call?.prompt).toContain("<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>");
1591+
expect(call?.prompt).toContain("secret cron awareness payload");
1592+
// User-visible transcript drops the wrap and the wrapped body.
1593+
expect(call?.transcriptPrompt).toBeDefined();
1594+
expect(call?.transcriptPrompt).not.toContain("<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>");
1595+
expect(call?.transcriptPrompt).not.toContain("<<<END_OPENCLAW_INTERNAL_CONTEXT>>>");
1596+
expect(call?.transcriptPrompt).not.toContain("secret cron awareness payload");
1597+
expect(call?.transcriptPrompt).toContain("user message body");
1598+
expect(call?.transcriptPrompt).toContain("second message");
1599+
});
1600+
1601+
it("omits transcriptPrompt when no items carry a wrap or transcript split", async () => {
1602+
const key = `test-collect-no-wrap-${Date.now()}`;
1603+
const calls: FollowupRun[] = [];
1604+
const done = createDeferred<void>();
1605+
const runFollowup = async (run: FollowupRun) => {
1606+
calls.push(run);
1607+
done.resolve();
1608+
};
1609+
const settings: QueueSettings = {
1610+
mode: "collect",
1611+
debounceMs: 0,
1612+
cap: 50,
1613+
dropPolicy: "summarize",
1614+
};
1615+
1616+
enqueueFollowupRun(
1617+
key,
1618+
createRun({
1619+
prompt: "plain one",
1620+
originatingChannel: "slack",
1621+
originatingTo: "channel:A",
1622+
}),
1623+
settings,
1624+
);
1625+
enqueueFollowupRun(
1626+
key,
1627+
createRun({
1628+
prompt: "plain two",
1629+
originatingChannel: "slack",
1630+
originatingTo: "channel:A",
1631+
}),
1632+
settings,
1633+
);
1634+
1635+
scheduleFollowupDrain(key, runFollowup);
1636+
await done.promise;
1637+
1638+
const call = calls[0];
1639+
expect(call?.prompt).toContain("plain one");
1640+
expect(call?.prompt).toContain("plain two");
1641+
expect(call?.transcriptPrompt).toBeUndefined();
1642+
});
1643+
});

src/auto-reply/reply/queue.test-helpers.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export function createDeferred<T>() {
1515

1616
export function createQueueTestRun(params: {
1717
prompt: string;
18+
transcriptPrompt?: string;
1819
messageId?: string;
1920
originatingChannel?: FollowupRun["originatingChannel"];
2021
originatingTo?: string;
@@ -24,6 +25,7 @@ export function createQueueTestRun(params: {
2425
}): FollowupRun {
2526
return {
2627
prompt: params.prompt,
28+
...(params.transcriptPrompt !== undefined ? { transcriptPrompt: params.transcriptPrompt } : {}),
2729
messageId: params.messageId,
2830
enqueuedAt: Date.now(),
2931
originatingChannel: params.originatingChannel,

src/auto-reply/reply/queue/drain.ts

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import {
2+
hasInternalRuntimeContext,
3+
stripInternalRuntimeContext,
4+
} from "../../../agents/internal-runtime-context.js";
15
import { channelRouteCompactKey } from "../../../plugin-sdk/channel-route.js";
26
import { defaultRuntime } from "../../../runtime.js";
37
import { resolveGlobalMap } from "../../../shared/global-singleton.js";
@@ -135,11 +139,26 @@ function splitCollectItemsByAuthorization(items: FollowupRun[]): FollowupRun[][]
135139
return groups;
136140
}
137141

138-
function renderCollectItem(item: FollowupRun, idx: number): string {
142+
function resolveCollectSenderSuffix(item: FollowupRun): string {
139143
const senderLabel =
140144
item.run.senderName ?? item.run.senderUsername ?? item.run.senderId ?? item.run.senderE164;
141-
const senderSuffix = senderLabel ? ` (from ${senderLabel})` : "";
142-
return `---\nQueued #${idx + 1}${senderSuffix}\n${item.prompt}`.trim();
145+
return senderLabel ? ` (from ${senderLabel})` : "";
146+
}
147+
148+
function renderCollectItem(item: FollowupRun, idx: number): string {
149+
return `---\nQueued #${idx + 1}${resolveCollectSenderSuffix(item)}\n${item.prompt}`.trim();
150+
}
151+
152+
// Transcript-safe variant: prefer the run's own transcriptPrompt (already
153+
// stripped at enqueue time by get-reply-run.ts); fall back to stripping
154+
// runtime-context wraps off the model-facing prompt. Used to build the
155+
// `transcriptPrompt` on the collected follow-up so wrapped internal events
156+
// drained into `item.prompt` (e.g. audience: "internal" cron awareness)
157+
// stay hidden from the user-visible transcript even when collect-mode
158+
// queuing concatenates queued items.
159+
function renderCollectItemTranscript(item: FollowupRun, idx: number): string {
160+
const transcriptBody = item.transcriptPrompt ?? stripInternalRuntimeContext(item.prompt);
161+
return `---\nQueued #${idx + 1}${resolveCollectSenderSuffix(item)}\n${transcriptBody}`.trim();
143162
}
144163

145164
function collectQueuedImages(items: FollowupRun[]): Pick<FollowupRun, "images" | "imageOrder"> {
@@ -473,9 +492,29 @@ export function scheduleFollowupDrain(
473492
summary: pendingSummary,
474493
renderItem: renderCollectItem,
475494
});
495+
// Produce a transcript-safe counterpart whenever the model-facing
496+
// prompt carries an INTERNAL_RUNTIME_CONTEXT wrap or any item
497+
// already split its prompt vs transcriptPrompt at enqueue time
498+
// (see get-reply-run.ts where queuedBody vs transcriptCommandBody
499+
// are split). Without this, audience: "internal" events drained
500+
// into item.prompt during an active run would surface in the
501+
// user-visible transcript via the combined collect prompt.
502+
const itemHasTranscriptSplit = groupItems.some(
503+
(item) => item.transcriptPrompt !== undefined,
504+
);
505+
const transcriptPrompt =
506+
hasInternalRuntimeContext(prompt) || itemHasTranscriptSplit
507+
? buildCollectPrompt({
508+
title: "[Queued messages while agent was busy]",
509+
items: groupItems,
510+
summary: pendingSummary,
511+
renderItem: renderCollectItemTranscript,
512+
})
513+
: undefined;
476514
const drainGroup = async () => {
477515
await effectiveRunFollowup({
478516
prompt,
517+
...(transcriptPrompt !== undefined ? { transcriptPrompt } : {}),
479518
run,
480519
enqueuedAt: Date.now(),
481520
...routing,

src/auto-reply/reply/session-system-events.ts

Lines changed: 90 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
import { resolveUserTimezone } from "../../agents/date-time.js";
2+
import {
3+
escapeInternalRuntimeContextDelimiters,
4+
INTERNAL_RUNTIME_CONTEXT_BEGIN,
5+
INTERNAL_RUNTIME_CONTEXT_END,
6+
} from "../../agents/internal-runtime-context.js";
27
import type { OpenClawConfig } from "../../config/types.openclaw.js";
38
import { buildChannelSummary } from "../../infra/channel-summary.js";
49
import {
@@ -11,32 +16,74 @@ import {
1116
consumeSelectedSystemEventEntries,
1217
peekSystemEventEntries,
1318
type SystemEvent,
19+
type SystemEventAudience,
1420
} from "../../infra/system-events.js";
1521
import {
1622
normalizeLowercaseStringOrEmpty,
1723
normalizeOptionalString,
1824
} from "../../shared/string-coerce.js";
1925

20-
function selectGenericSystemEvents(events: readonly SystemEvent[]): SystemEvent[] {
21-
return events.filter((event) => !isExecCompletionEvent(event.text));
26+
// Exclude user-facing exec-completion events so the heartbeat path can
27+
// consume them via its own relay surface. `audience: "internal"` events
28+
// always belong on this generic drain (which routes them to the
29+
// INTERNAL_RUNTIME_CONTEXT wrap) regardless of text shape — otherwise an
30+
// exec-shaped internal event (e.g. cron output literally starting with
31+
// "Exec finished...") falls into a no-consumer hole: this filter would
32+
// strand it for the heartbeat path, but the heartbeat exec/consume
33+
// selectors skip internal events. The audience field is the source of
34+
// truth for routing; text-shape only matters for user-facing events.
35+
//
36+
// `isHeartbeat=true` callers leave `audience: "internal"` events queued.
37+
// Heartbeat replies use `buildReplyPromptEnvelopeBase`'s fixed transcript
38+
// prompt and do not preserve `systemEventBlocks` on the prompt body, so
39+
// draining (= consuming) an internal event during a heartbeat would
40+
// silently drop the wrapped runtime-context block before the next
41+
// non-heartbeat user turn can see it. Wait for the regular reply turn,
42+
// which DOES carry `systemEventBlocks` and therefore actually delivers
43+
// the wrapped context to the model.
44+
function selectGenericSystemEvents(
45+
events: readonly SystemEvent[],
46+
isHeartbeat: boolean,
47+
): SystemEvent[] {
48+
const selected: SystemEvent[] = [];
49+
for (const event of events) {
50+
if (event.audience === "internal") {
51+
if (!isHeartbeat) {
52+
selected.push(event);
53+
}
54+
continue;
55+
}
56+
if (!isExecCompletionEvent(event.text)) {
57+
selected.push(event);
58+
}
59+
}
60+
return selected;
2261
}
2362

24-
function compactSystemEvent(line: string): string | null {
63+
function compactSystemEvent(line: string, audience: SystemEventAudience): string | null {
2564
const trimmed = line.trim();
2665
if (!trimmed) {
2766
return null;
2867
}
29-
const lower = normalizeLowercaseStringOrEmpty(trimmed);
30-
if (lower.includes("reason periodic")) {
31-
return null;
32-
}
33-
// Filter out the actual heartbeat prompt, but not cron jobs that mention "heartbeat".
34-
// The heartbeat prompt starts with "Read HEARTBEAT.md" - cron payloads won't match this.
35-
if (lower.startsWith("read heartbeat.md")) {
36-
return null;
37-
}
38-
if (lower.includes("heartbeat poll") || lower.includes("heartbeat wake")) {
39-
return null;
68+
// Heartbeat-noise filters keep user-facing relay prompts clean. They do
69+
// NOT apply to audience: "internal" events — those go through the
70+
// wrap-on-drain path and never reach a user-facing surface, so the
71+
// filter would silently drop the event after consumption (same
72+
// no-consumer hole class as the exec-shape filter). The Node:
73+
// transformation below is a sanitizer that runs for both audiences.
74+
if (audience !== "internal") {
75+
const lower = normalizeLowercaseStringOrEmpty(trimmed);
76+
if (lower.includes("reason periodic")) {
77+
return null;
78+
}
79+
// Filter out the actual heartbeat prompt, but not cron jobs that mention "heartbeat".
80+
// The heartbeat prompt starts with "Read HEARTBEAT.md" - cron payloads won't match this.
81+
if (lower.startsWith("read heartbeat.md")) {
82+
return null;
83+
}
84+
if (lower.includes("heartbeat poll") || lower.includes("heartbeat wake")) {
85+
return null;
86+
}
4087
}
4188
if (trimmed.startsWith("Node:")) {
4289
return trimmed.replace(/ · last input [^·]+/i, "").trim();
@@ -89,27 +136,52 @@ export async function drainFormattedSystemEvents(params: {
89136
sessionKey: string;
90137
isMainSession: boolean;
91138
isNewSession: boolean;
139+
isHeartbeat?: boolean;
92140
}): Promise<string | undefined> {
93141
const summaryLines: string[] = [];
94-
const systemLines: string[] = [];
142+
const userFacingLines: string[] = [];
143+
const internalLines: string[] = [];
95144
// Exec completions have a dedicated heartbeat prompt; leave those entries queued
96145
// so the heartbeat path can consume and deliver them.
97146
const queued = consumeSelectedSystemEventEntries(
98147
params.sessionKey,
99-
selectGenericSystemEvents(peekSystemEventEntries(params.sessionKey)),
148+
selectGenericSystemEvents(
149+
peekSystemEventEntries(params.sessionKey),
150+
params.isHeartbeat === true,
151+
),
100152
);
101153
for (const event of queued) {
102-
const compacted = compactSystemEvent(event.text);
154+
const audience: SystemEventAudience = event.audience ?? "user-facing";
155+
const compacted = compactSystemEvent(event.text, audience);
103156
if (!compacted) {
104157
continue;
105158
}
106159
const timestamp = `[${formatSystemEventTimestamp(event.ts, params.cfg)}]`;
160+
const target = audience === "internal" ? internalLines : userFacingLines;
107161
let index = 0;
108162
for (const subline of compacted.split("\n")) {
109-
systemLines.push(`System: ${index === 0 ? `${timestamp} ` : ""}${subline}`);
163+
target.push(`System: ${index === 0 ? `${timestamp} ` : ""}${subline}`);
110164
index += 1;
111165
}
112166
}
167+
const systemLines: string[] = [...userFacingLines];
168+
if (internalLines.length > 0) {
169+
// Wrap internal-audience events in the runtime-context delimiters so the
170+
// agent runtime sees the content but user-facing surfaces strip it via
171+
// the existing stripInternalRuntimeContext consumers. Framing matches
172+
// `formatAgentInternalEventsForPrompt` in `src/agents/internal-events.ts`
173+
// (BEGIN, header, blank line, body, END): the header line tells the
174+
// model the wrapped block is runtime context, not user-authored, so it
175+
// should not echo the contents back into its reply.
176+
systemLines.push(INTERNAL_RUNTIME_CONTEXT_BEGIN);
177+
systemLines.push("OpenClaw runtime context (internal):");
178+
systemLines.push(
179+
"This context is runtime-generated, not user-authored. Keep internal details private.",
180+
);
181+
systemLines.push("");
182+
systemLines.push(escapeInternalRuntimeContextDelimiters(internalLines.join("\n")));
183+
systemLines.push(INTERNAL_RUNTIME_CONTEXT_END);
184+
}
113185
if (params.isMainSession && params.isNewSession) {
114186
const summary = await buildChannelSummary(params.cfg);
115187
if (summary.length > 0) {

0 commit comments

Comments
 (0)