Skip to content

Commit 9180173

Browse files
committed
fix: preserve exec event routing and sanitize tool XML
1 parent 7b5d956 commit 9180173

11 files changed

Lines changed: 250 additions & 27 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ Docs: https://docs.openclaw.ai
4141
- Telegram/startup: use the existing `getMe` request guard for the gateway bot probe instead of a fixed 2.5-second budget, and honor higher `timeoutSeconds` configs for slow Telegram API paths. Fixes #75783. Thanks @tankotan.
4242
- Telegram/models: make model picker confirmations say selections are session-scoped and do not change the agent's persistent default. Fixes #75965. Thanks @sd1114820.
4343
- Control UI/slash commands: keep fallback command metadata on a browser-safe registry path, so provider thinking runtime imports cannot blank the Web UI with `process is not defined`. Fixes #75987. Thanks @novkien.
44+
- Heartbeat/Discord: keep async exec completion events out of the generic `System (untrusted)` prompt block and let the dedicated exec heartbeat prompt handle them, so Discord no longer receives raw exec failure tails as separate system-style messages. Fixes #66366. Thanks @Promee-ThaBossHoss.
45+
- Channels: strip plain-text MiniMax and XML tool-call scaffolding from shared user-facing reply sanitization, so messaging channels do not deliver raw model tool syntax when a provider emits it as text instead of structured tool calls. Fixes #62820. Thanks @canh0chua.
4446
- Infer/media: report missing image-understanding and audio-transcription provider configuration for `image describe`, `image describe-many`, and `audio transcribe` instead of blaming the input path when no provider is available. Fixes #73569 and supersedes #73593, #74288, and #74495. Thanks @bittoby, @tmimmanuel, @Linux2010, and @vyctorbrzezowski.
4547
- Docs/health: clarify that session listing surfaces stored conversation rows rather than Discord/channel socket liveness, and point connectivity checks at channel status and health probes. Fixes #70420. Thanks @ashersoutherncities-art and @martingarramon.
4648
- WhatsApp/Cron: keep DM pairing-store approvals out of implicit cron and heartbeat recipient fallback, so scheduled automation only uses explicit targets, active configured recipients, or configured `allowFrom` entries. Fixes #62339. Thanks @kelvinisly-collab.

src/agents/bash-tools.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -766,7 +766,7 @@ describe("exec notifyOnExit", () => {
766766
expect(finished).toBeTruthy();
767767
expect(hasEvent).toBe(true);
768768
expect(queuedEvent).toMatchObject({ trusted: false });
769-
expect(formatted).toContain("System (untrusted):");
769+
expect(formatted).toBeUndefined();
770770
});
771771

772772
it("preserves the origin delivery context on background exec completion events", async () => {

src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,56 @@ describe("sanitizeUserFacingText", () => {
235235
expect(sanitizeUserFacingText(input)).toBe("Before\n\nAfter");
236236
});
237237

238+
it("strips MiniMax plain-text tool calls before user-facing delivery", () => {
239+
const input = [
240+
"Let me check that.",
241+
'<minimax:tool_call><invoke name="exec">',
242+
'<parameter name="cmd">ls</parameter>',
243+
"</invoke></minimax:tool_call>",
244+
"Done.",
245+
].join("\n");
246+
247+
expect(sanitizeUserFacingText(input)).toBe("Let me check that.\n\nDone.");
248+
});
249+
250+
it("preserves MiniMax tool-call XML examples in user-facing code spans", () => {
251+
const inline = 'Use `<minimax:tool_call><invoke name="exec">x</invoke></minimax:tool_call>`.';
252+
const fenced = [
253+
"Example:",
254+
"```xml",
255+
'<minimax:tool_call><invoke name="exec">x</invoke></minimax:tool_call>',
256+
"```",
257+
].join("\n");
258+
259+
expect(sanitizeUserFacingText(inline)).toBe(inline);
260+
expect(sanitizeUserFacingText(fenced)).toBe(fenced);
261+
});
262+
263+
it("strips raw XML tool-call blocks before user-facing delivery", () => {
264+
const input = [
265+
"Before",
266+
'<tool_call>{"name":"read","arguments":{"file_path":"secret.md"}}</tool_call>',
267+
"After",
268+
].join("\n");
269+
270+
expect(sanitizeUserFacingText(input)).toBe("Before\n\nAfter");
271+
});
272+
273+
it("strips plural XML function-call wrappers before user-facing delivery", () => {
274+
const input = [
275+
"Before",
276+
'<function_calls><invoke name="find"><parameter name="query">secret</parameter></invoke></function_calls>',
277+
"After",
278+
].join("\n");
279+
280+
expect(sanitizeUserFacingText(input)).toBe("Before\n\nAfter");
281+
});
282+
283+
it("preserves literal tool-call tag examples in user-facing prose", () => {
284+
const input = "Use `<tool_call>` to describe the XML tag in docs.";
285+
expect(sanitizeUserFacingText(input)).toBe(input);
286+
});
287+
238288
it("keeps ordinary inline mentions of the replay placeholder", () => {
239289
expect(sanitizeUserFacingText("What does [tool calls omitted] mean?")).toBe(
240290
"What does [tool calls omitted] mean?",

src/agents/pi-embedded-helpers/sanitize-user-facing-text.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@ import {
1212
normalizeLowercaseStringOrEmpty,
1313
normalizeOptionalLowercaseString,
1414
} from "../../shared/string-coerce.js";
15-
import { stripLegacyBracketToolCallBlocks } from "../../shared/text/assistant-visible-text.js";
15+
import {
16+
stripLegacyBracketToolCallBlocks,
17+
stripMinimaxToolCallXml,
18+
stripToolCallXmlTags,
19+
} from "../../shared/text/assistant-visible-text.js";
1620
import { formatExecDeniedUserMessage } from "../exec-approval-result.js";
1721
import { stripInternalRuntimeContext } from "../internal-runtime-context.js";
1822
import { stableStringify } from "../stable-stringify.js";
@@ -401,10 +405,13 @@ export function sanitizeUserFacingText(text: unknown, opts?: { errorContext?: bo
401405
}
402406
const errorContext = opts?.errorContext ?? false;
403407
const stripped = stripInboundMetadata(stripInternalRuntimeContext(stripFinalTagsFromText(raw)));
408+
const withoutToolCallXml = stripToolCallXmlTags(stripMinimaxToolCallXml(stripped), {
409+
stripFunctionCallsXmlPayloads: true,
410+
});
404411
// Replay repair may synthesize this placeholder to keep provider transcripts valid.
405412
// It is internal scaffolding, so drop standalone placeholder lines before delivery
406413
// while preserving ordinary inline mentions a user may be discussing.
407-
const withoutPlaceholder = stripToolCallsOmittedPlaceholderLines(stripped);
414+
const withoutPlaceholder = stripToolCallsOmittedPlaceholderLines(withoutToolCallXml);
408415
const withoutToolCallBlocks = stripLegacyBracketToolCallBlocks(withoutPlaceholder);
409416
const trimmed = withoutToolCallBlocks.trim();
410417
if (!trimmed) {

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,20 @@ import {
66
formatZonedTimestamp,
77
resolveTimezone,
88
} from "../../infra/format-time/format-datetime.ts";
9-
import { drainSystemEventEntries } from "../../infra/system-events.js";
9+
import { isExecCompletionEvent } from "../../infra/heartbeat-events-filter.js";
10+
import {
11+
consumeSelectedSystemEventEntries,
12+
peekSystemEventEntries,
13+
type SystemEvent,
14+
} from "../../infra/system-events.js";
1015
import {
1116
normalizeLowercaseStringOrEmpty,
1217
normalizeOptionalString,
1318
} from "../../shared/string-coerce.js";
1419

20+
const selectGenericSystemEvents = (events: readonly SystemEvent[]): SystemEvent[] =>
21+
events.filter((event) => !isExecCompletionEvent(event.text));
22+
1523
/** Drain queued system events, format as `System:` lines, return the block (or undefined). */
1624
export async function drainFormattedSystemEvents(params: {
1725
cfg: OpenClawConfig;
@@ -83,7 +91,12 @@ export async function drainFormattedSystemEvents(params: {
8391
};
8492

8593
const systemLines: string[] = [];
86-
const queued = drainSystemEventEntries(params.sessionKey);
94+
// Exec completions have a dedicated heartbeat prompt; leave those entries queued
95+
// so the heartbeat path can consume and deliver them.
96+
const queued = consumeSelectedSystemEventEntries(
97+
params.sessionKey,
98+
selectGenericSystemEvents(peekSystemEventEntries(params.sessionKey)),
99+
);
87100
systemLines.push(
88101
...queued.flatMap((event) => {
89102
const compacted = compactSystemEvent(event.text);

src/infra/heartbeat-runner.ghost-reminder.test.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
setupTelegramHeartbeatPluginRuntimeForTests,
99
withTempHeartbeatSandbox,
1010
} from "./heartbeat-runner.test-utils.js";
11-
import { enqueueSystemEvent, resetSystemEventsForTest } from "./system-events.js";
11+
import { enqueueSystemEvent, peekSystemEvents, resetSystemEventsForTest } from "./system-events.js";
1212

1313
beforeEach(() => {
1414
setupTelegramHeartbeatPluginRuntimeForTests();
@@ -143,6 +143,7 @@ describe("Ghost reminder bug (issue #13317)", () => {
143143
SessionKey?: string;
144144
ForceSenderIsOwnerFalse?: boolean;
145145
} | null;
146+
sessionKey: string;
146147
replyCallCount: number;
147148
}> => {
148149
return withTempHeartbeatSandbox(
@@ -174,6 +175,7 @@ describe("Ghost reminder bug (issue #13317)", () => {
174175
result,
175176
sendTelegram,
176177
calledCtx,
178+
sessionKey,
177179
replyCallCount: getReplySpy.mock.calls.length,
178180
};
179181
},
@@ -375,6 +377,26 @@ describe("Ghost reminder bug (issue #13317)", () => {
375377
expect(sendTelegram).toHaveBeenCalled();
376378
});
377379

380+
it("consumes exec completion entries without dropping later generic events", async () => {
381+
const { result, calledCtx, sessionKey } = await runHeartbeatCase({
382+
tmpPrefix: "openclaw-exec-preserve-generic-",
383+
replyText: "Deploy succeeded",
384+
reason: "exec-event",
385+
enqueue: (key) => {
386+
enqueueSystemEvent("Exec finished (gateway id=abc12345, code 0)\ndeploy succeeded", {
387+
sessionKey: key,
388+
});
389+
enqueueSystemEvent("Node connected", { sessionKey: key });
390+
},
391+
});
392+
393+
expect(result.status).toBe("ran");
394+
expect(calledCtx?.Provider).toBe("exec-event");
395+
expect(calledCtx?.Body).toContain("deploy succeeded");
396+
expect(calledCtx?.Body).not.toContain("Node connected");
397+
expect(peekSystemEvents(sessionKey)).toEqual(["Node connected"]);
398+
});
399+
378400
it("classifies hook:wake exec completions as exec-event prompts", async () => {
379401
const { result, sendTelegram, calledCtx } = await runHeartbeatCase({
380402
tmpPrefix: "openclaw-hook-exec-",

src/infra/heartbeat-runner.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -131,9 +131,10 @@ import {
131131
resolveHeartbeatSenderContext,
132132
} from "./outbound/targets.js";
133133
import {
134-
consumeSystemEventEntries,
134+
consumeSelectedSystemEventEntries,
135135
peekSystemEventEntries,
136136
resolveSystemEventDeliveryContext,
137+
type SystemEvent,
137138
} from "./system-events.js";
138139

139140
export type HeartbeatDeps = OutboundSendDeps &
@@ -950,6 +951,28 @@ After completing all due tasks, reply HEARTBEAT_OK.`;
950951
};
951952
}
952953

954+
function selectSystemEventsConsumedByHeartbeat(params: {
955+
preflight: HeartbeatPreflight;
956+
hasExecCompletion: boolean;
957+
hasCronEvents: boolean;
958+
}): SystemEvent[] {
959+
const { preflight } = params;
960+
if (!preflight.shouldInspectPendingEvents || preflight.pendingEventEntries.length === 0) {
961+
return [];
962+
}
963+
if (params.hasExecCompletion) {
964+
return preflight.pendingEventEntries.filter((event) => isExecCompletionEvent(event.text));
965+
}
966+
if (params.hasCronEvents) {
967+
return preflight.pendingEventEntries.filter(
968+
(event) =>
969+
(preflight.isCronEventReason || event.contextKey?.startsWith("cron:")) &&
970+
isCronSystemEvent(event.text),
971+
);
972+
}
973+
return preflight.pendingEventEntries;
974+
}
975+
953976
export async function runHeartbeatOnce(opts: {
954977
cfg?: OpenClawConfig;
955978
agentId?: string;
@@ -1127,15 +1150,20 @@ export async function runHeartbeatOnce(opts: {
11271150
const dueCommitmentIds = hasDueCommitments
11281151
? preflight.dueCommitments.map((commitment) => commitment.id)
11291152
: [];
1153+
const inspectedSystemEventsToConsume = selectSystemEventsConsumedByHeartbeat({
1154+
preflight,
1155+
hasExecCompletion,
1156+
hasCronEvents,
1157+
});
11301158

11311159
// If no tasks are due, skip heartbeat entirely
11321160
if (prompt === null) {
11331161
// Wake-triggered events should stay queued when the run short-circuits:
11341162
// no reply turn ran, so there is nothing that actually consumed that wake payload.
11351163
const shouldConsumeInspectedEvents =
11361164
!preflight.isWakeReason && preflight.shouldInspectPendingEvents;
1137-
if (shouldConsumeInspectedEvents && preflight.pendingEventEntries.length > 0) {
1138-
consumeSystemEventEntries(sessionKey, preflight.pendingEventEntries);
1165+
if (shouldConsumeInspectedEvents && inspectedSystemEventsToConsume.length > 0) {
1166+
consumeSelectedSystemEventEntries(sessionKey, inspectedSystemEventsToConsume);
11391167
}
11401168
return { status: "skipped", reason: "no-tasks-due" };
11411169
}
@@ -1240,10 +1268,10 @@ export async function runHeartbeatOnce(opts: {
12401268
};
12411269

12421270
const consumeInspectedSystemEvents = () => {
1243-
if (!preflight.shouldInspectPendingEvents || preflight.pendingEventEntries.length === 0) {
1271+
if (!preflight.shouldInspectPendingEvents || inspectedSystemEventsToConsume.length === 0) {
12441272
return;
12451273
}
1246-
consumeSystemEventEntries(sessionKey, preflight.pendingEventEntries);
1274+
consumeSelectedSystemEventEntries(sessionKey, inspectedSystemEventsToConsume);
12471275
};
12481276

12491277
const promptWithHeartbeatTool = appendHeartbeatResponseToolPrompt(prompt);

src/infra/system-events.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { OpenClawConfig } from "../config/config.js";
44
import { resolveMainSessionKey } from "../config/sessions/main-session.js";
55
import { isCronSystemEvent } from "./heartbeat-events-filter.js";
66
import {
7+
consumeSelectedSystemEventEntries,
78
consumeSystemEventEntries,
89
drainSystemEventEntries,
910
enqueueSystemEvent,
@@ -120,6 +121,20 @@ describe("system events (session routing)", () => {
120121
expect(peekSystemEvents(key)).toEqual(["second"]);
121122
});
122123

124+
it("consumes selected inspected entries and preserves unselected queued events", () => {
125+
const key = "agent:main:test-consume-selected";
126+
enqueueSystemEvent("first", { sessionKey: key, contextKey: "event:first" });
127+
enqueueSystemEvent("second", { sessionKey: key, contextKey: "event:second" });
128+
enqueueSystemEvent("third", { sessionKey: key, contextKey: "event:third" });
129+
const selected = peekSystemEventEntries(key).filter((event) => event.text !== "second");
130+
131+
expect(consumeSelectedSystemEventEntries(key, selected).map((entry) => entry.text)).toEqual([
132+
"first",
133+
"third",
134+
]);
135+
expect(peekSystemEvents(key)).toEqual(["second"]);
136+
});
137+
123138
it("matches consumed delivery contexts through normalized route identity", () => {
124139
const key = "agent:main:test-consume-route-context";
125140
enqueueSystemEvent("first", {
@@ -211,6 +226,32 @@ describe("system events (session routing)", () => {
211226
expect(peekSystemEvents(key)).toEqual([]);
212227
});
213228

229+
it("leaves exec completion events queued for the dedicated heartbeat", async () => {
230+
const key = "agent:main:test-exec-completion-filter";
231+
enqueueSystemEvent("Exec failed (abc12345, signal SIGTERM) :: browser auth timed out", {
232+
sessionKey: key,
233+
trusted: false,
234+
});
235+
236+
const result = await drainFormattedEvents(key);
237+
expect(result).toBeUndefined();
238+
expect(peekSystemEvents(key)).toEqual([
239+
"Exec failed (abc12345, signal SIGTERM) :: browser auth timed out",
240+
]);
241+
});
242+
243+
it("drains generic events without consuming pending exec completions", async () => {
244+
const key = "agent:main:test-exec-completion-prefix";
245+
enqueueSystemEvent("Model switched to gpt-5.5", { sessionKey: key });
246+
enqueueSystemEvent("Exec finished (gateway id=abc12345, code 0)", { sessionKey: key });
247+
enqueueSystemEvent("Node connected", { sessionKey: key });
248+
249+
const result = await drainFormattedEvents(key);
250+
expect(result).toContain("Model switched to gpt-5.5");
251+
expect(result).toContain("Node connected");
252+
expect(peekSystemEvents(key)).toEqual(["Exec finished (gateway id=abc12345, code 0)"]);
253+
});
254+
214255
it("prefixes every line of a multi-line event", async () => {
215256
const key = "agent:main:test-multiline";
216257
enqueueSystemEvent("Post-compaction context:\nline one\nline two", { sessionKey: key });

src/infra/system-events.ts

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,18 @@ function areSystemEventsEqual(left: SystemEvent, right: SystemEvent): boolean {
149149
);
150150
}
151151

152+
function resetQueueState(key: string, entry: SessionQueue) {
153+
if (entry.queue.length === 0) {
154+
entry.lastText = null;
155+
entry.lastContextKey = null;
156+
queues.delete(key);
157+
return;
158+
}
159+
const newest = entry.queue[entry.queue.length - 1];
160+
entry.lastText = newest.text;
161+
entry.lastContextKey = newest.contextKey ?? null;
162+
}
163+
152164
export function consumeSystemEventEntries(
153165
sessionKey: string,
154166
consumedEntries: readonly SystemEvent[],
@@ -165,15 +177,31 @@ export function consumeSystemEventEntries(
165177
return [];
166178
}
167179
const removed = entry.queue.splice(0, consumedEntries.length).map(cloneSystemEvent);
168-
if (entry.queue.length === 0) {
169-
entry.lastText = null;
170-
entry.lastContextKey = null;
171-
queues.delete(key);
172-
} else {
173-
const newest = entry.queue[entry.queue.length - 1];
174-
entry.lastText = newest.text;
175-
entry.lastContextKey = newest.contextKey ?? null;
180+
resetQueueState(key, entry);
181+
return removed;
182+
}
183+
184+
export function consumeSelectedSystemEventEntries(
185+
sessionKey: string,
186+
consumedEntries: readonly SystemEvent[],
187+
): SystemEvent[] {
188+
const key = requireSessionKey(sessionKey);
189+
const entry = getSessionQueue(key);
190+
if (!entry || entry.queue.length === 0 || consumedEntries.length === 0) {
191+
return [];
192+
}
193+
const removed: SystemEvent[] = [];
194+
for (const consumed of consumedEntries) {
195+
const index = entry.queue.findIndex((event) => areSystemEventsEqual(event, consumed));
196+
if (index === -1) {
197+
continue;
198+
}
199+
const [event] = entry.queue.splice(index, 1);
200+
if (event) {
201+
removed.push(cloneSystemEvent(event));
202+
}
176203
}
204+
resetQueueState(key, entry);
177205
return removed;
178206
}
179207

0 commit comments

Comments
 (0)