Skip to content

Commit 6f7d2c4

Browse files
committed
fix(agents): deliver background exec completion to agent via [OpenClaw exec completion]
Respect explicit heartbeat target:none for webchat relay. Update transcripts and filters for exec completion visibility.
1 parent 301213a commit 6f7d2c4

8 files changed

Lines changed: 176 additions & 10 deletions

src/auto-reply/heartbeat-filter.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22
import { describe, expect, it } from "vitest";
33
import {
44
filterHeartbeatTranscriptArtifacts,
5+
isExecCompletionUserMessage,
56
isHeartbeatOkResponse,
67
isHeartbeatUserMessage,
78
} from "./heartbeat-filter.js";
89
import {
10+
EXEC_COMPLETION_TRANSCRIPT_PROMPT,
911
HEARTBEAT_RESPONSE_TOOL_PROMPT,
1012
HEARTBEAT_PROMPT,
1113
HEARTBEAT_TRANSCRIPT_PROMPT,
@@ -46,6 +48,13 @@ describe("isHeartbeatUserMessage", () => {
4648
}),
4749
).toBe(true);
4850

51+
expect(
52+
isHeartbeatUserMessage({
53+
role: "user",
54+
content: EXEC_COMPLETION_TRANSCRIPT_PROMPT,
55+
}),
56+
).toBe(true);
57+
4958
const customHeartbeatPrompt = "Check the handoff queue.";
5059
expect(
5160
isHeartbeatUserMessage(
@@ -136,6 +145,49 @@ describe("isHeartbeatOkResponse", () => {
136145
});
137146
});
138147

148+
describe("isExecCompletionUserMessage", () => {
149+
it("matches exec completion transcript prompt", () => {
150+
expect(
151+
isExecCompletionUserMessage({
152+
role: "user",
153+
content: EXEC_COMPLETION_TRANSCRIPT_PROMPT,
154+
}),
155+
).toBe(true);
156+
});
157+
158+
it("matches exec completion prompt with exec details appended", () => {
159+
expect(
160+
isExecCompletionUserMessage({
161+
role: "user",
162+
content: `${EXEC_COMPLETION_TRANSCRIPT_PROMPT}\n\nExec completed (session-id, code 0) :: done`,
163+
}),
164+
).toBe(true);
165+
});
166+
167+
it("rejects non-exec messages", () => {
168+
expect(
169+
isExecCompletionUserMessage({
170+
role: "user",
171+
content: "hello",
172+
}),
173+
).toBe(false);
174+
175+
expect(
176+
isExecCompletionUserMessage({
177+
role: "user",
178+
content: HEARTBEAT_TRANSCRIPT_PROMPT,
179+
}),
180+
).toBe(false);
181+
182+
expect(
183+
isExecCompletionUserMessage({
184+
role: "assistant",
185+
content: EXEC_COMPLETION_TRANSCRIPT_PROMPT,
186+
}),
187+
).toBe(false);
188+
});
189+
});
190+
139191
describe("filterHeartbeatTranscriptArtifacts", () => {
140192
it("removes no-op heartbeat pairs", () => {
141193
const messages = [
@@ -991,4 +1043,24 @@ describe("filterHeartbeatTranscriptArtifacts", () => {
9911043
messages,
9921044
);
9931045
});
1046+
1047+
it("removes exec completion pairs alongside heartbeat pairs", () => {
1048+
const messages = [
1049+
{ role: "user", content: "Hello" },
1050+
{ role: "assistant", content: "Hi!" },
1051+
{ role: "user", content: EXEC_COMPLETION_TRANSCRIPT_PROMPT },
1052+
{ role: "assistant", content: "HEARTBEAT_OK" },
1053+
{ role: "user", content: HEARTBEAT_TRANSCRIPT_PROMPT },
1054+
{ role: "assistant", content: "HEARTBEAT_OK" },
1055+
{ role: "user", content: "What time is it?" },
1056+
{ role: "assistant", content: "3pm." },
1057+
];
1058+
1059+
expect(filterHeartbeatTranscriptArtifacts(messages, undefined, HEARTBEAT_PROMPT)).toEqual([
1060+
{ role: "user", content: "Hello" },
1061+
{ role: "assistant", content: "Hi!" },
1062+
{ role: "user", content: "What time is it?" },
1063+
{ role: "assistant", content: "3pm." },
1064+
]);
1065+
});
9941066
});

src/auto-reply/heartbeat-filter.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { normalizeOptionalString as readString } from "@openclaw/normalization-c
44
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
55
import { HEARTBEAT_RESPONSE_TOOL_NAME } from "./heartbeat-tool-response.js";
66
import {
7+
EXEC_COMPLETION_TRANSCRIPT_PROMPT,
78
HEARTBEAT_RESPONSE_TOOL_PROMPT,
89
HEARTBEAT_TRANSCRIPT_PROMPT,
910
resolveHeartbeatPromptForResponseTool,
@@ -324,11 +325,30 @@ export function isHeartbeatUserMessage(
324325
) {
325326
return true;
326327
}
328+
if (isExecCompletionUserMessage(message)) {
329+
return true;
330+
}
327331
return (
328332
trimmed.startsWith(HEARTBEAT_TASK_PROMPT_PREFIX) && trimmed.includes(HEARTBEAT_TASK_PROMPT_ACK)
329333
);
330334
}
331335

336+
/** Return whether a user message is an exec completion notification. */
337+
export function isExecCompletionUserMessage(message: { role: string; content?: unknown }): boolean {
338+
if (message.role !== "user") {
339+
return false;
340+
}
341+
const { text } = resolveMessageText(message.content);
342+
const trimmed = text.trim();
343+
if (!trimmed) {
344+
return false;
345+
}
346+
return (
347+
trimmed === EXEC_COMPLETION_TRANSCRIPT_PROMPT ||
348+
trimmed.startsWith(`${EXEC_COMPLETION_TRANSCRIPT_PROMPT}\n`)
349+
);
350+
}
351+
332352
/** Return whether an assistant message is only a heartbeat acknowledgement. */
333353
export function isHeartbeatOkResponse(
334354
message: { role: string; content?: unknown },

src/auto-reply/heartbeat.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ export const HEARTBEAT_RESPONSE_TOOL_INSTRUCTIONS =
2121
"Use heartbeat_respond to report the wake outcome. Set notify=false when nothing needs the user's attention. Set notify=true with notificationText only when the user should be interrupted.";
2222
export const HEARTBEAT_RESPONSE_TOOL_PROMPT = `${HEARTBEAT_CONTEXT_PROMPT} ${HEARTBEAT_RESPONSE_TOOL_INSTRUCTIONS}`;
2323
export const HEARTBEAT_TRANSCRIPT_PROMPT = "[OpenClaw heartbeat poll]";
24+
/** Transcript placeholder for background exec completion events. */
25+
export const EXEC_COMPLETION_TRANSCRIPT_PROMPT = "[OpenClaw exec completion]";
2426
export const DEFAULT_HEARTBEAT_EVERY = "30m";
2527
export const DEFAULT_HEARTBEAT_ACK_MAX_CHARS = 300;
2628

src/auto-reply/reply/prompt-prelude.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
// Tests prompt prelude construction for sender, routing, and context metadata.
22
import { describe, expect, it } from "vitest";
3+
import { EXEC_COMPLETION_TRANSCRIPT_PROMPT } from "../heartbeat.js";
4+
import { HEARTBEAT_TRANSCRIPT_PROMPT } from "../heartbeat.js";
35
import { finalizeInboundContext } from "./inbound-context.js";
46
import { buildReplyPromptEnvelope } from "./prompt-prelude.js";
57

@@ -216,4 +218,52 @@ describe("buildReplyPromptEnvelope", () => {
216218
expect(envelope.transcriptCommandBody).toBe("re-read persona files");
217219
expect(envelope.transcriptCommandBody).not.toContain("Startup context");
218220
});
221+
222+
it("uses exec completion transcript prompt for exec-event heartbeats", () => {
223+
const sessionCtx = finalizeInboundContext({
224+
Body: "An async command you ran earlier has completed. Details: ...",
225+
Provider: "exec-event",
226+
ChatType: "direct",
227+
});
228+
229+
const envelope = buildReplyPromptEnvelope({
230+
ctx: sessionCtx,
231+
sessionCtx,
232+
baseBody: "An async command you ran earlier has completed. Details: ...",
233+
prefixedBody: "An async command you ran earlier has completed. Details: ...",
234+
hasUserBody: true,
235+
inboundUserContext: "",
236+
isBareSessionReset: false,
237+
startupAction: "new",
238+
isHeartbeat: true,
239+
});
240+
241+
expect(envelope.transcriptCommandBody).toBe(
242+
`${EXEC_COMPLETION_TRANSCRIPT_PROMPT}\n\n${envelope.effectiveBaseBody}`,
243+
);
244+
expect(envelope.effectiveBaseBody).toContain("An async command you ran earlier has completed.");
245+
});
246+
247+
it("uses heartbeat transcript prompt for regular heartbeats", () => {
248+
const sessionCtx = finalizeInboundContext({
249+
Body: "Read HEARTBEAT.md if it exists.",
250+
Provider: "heartbeat",
251+
ChatType: "direct",
252+
});
253+
254+
const envelope = buildReplyPromptEnvelope({
255+
ctx: sessionCtx,
256+
sessionCtx,
257+
baseBody: "Read HEARTBEAT.md if it exists.",
258+
prefixedBody: "Read HEARTBEAT.md if it exists.",
259+
hasUserBody: true,
260+
inboundUserContext: "",
261+
isBareSessionReset: false,
262+
startupAction: "new",
263+
isHeartbeat: true,
264+
});
265+
266+
expect(envelope.transcriptCommandBody).toBe(HEARTBEAT_TRANSCRIPT_PROMPT);
267+
expect(envelope.transcriptCommandBody).not.toContain("Read HEARTBEAT.md");
268+
});
219269
});

src/auto-reply/reply/prompt-prelude.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { CurrentInboundPromptContext } from "../../agents/embedded-agent-ru
44
import type { InboundEventKind } from "../../channels/inbound-event/kind.js";
55
import { annotateInterSessionPromptText } from "../../sessions/input-provenance.js";
66
import type { SourceReplyDeliveryMode } from "../get-reply-options.types.js";
7-
import { HEARTBEAT_TRANSCRIPT_PROMPT } from "../heartbeat.js";
7+
import { EXEC_COMPLETION_TRANSCRIPT_PROMPT, HEARTBEAT_TRANSCRIPT_PROMPT } from "../heartbeat.js";
88
import { buildInboundMediaNote } from "../media-note.js";
99
import type { MsgContext, TemplateContext } from "../templating.js";
1010
import { appendUntrustedContext } from "./untrusted-context.js";
@@ -199,7 +199,9 @@ export function buildReplyPromptEnvelopeBase(
199199
? resetModelBody
200200
: "[User sent media without caption]";
201201
const transcriptBody = params.isHeartbeat
202-
? HEARTBEAT_TRANSCRIPT_PROMPT
202+
? params.ctx.Provider === "exec-event"
203+
? `${EXEC_COMPLETION_TRANSCRIPT_PROMPT}\n\n${effectiveBaseBody}`
204+
: HEARTBEAT_TRANSCRIPT_PROMPT
203205
: params.isBareSessionReset
204206
? softResetTail || `[OpenClaw session ${params.startupAction}]`
205207
: isRoomEvent

src/infra/heartbeat-events-filter.test.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,8 @@ describe("heartbeat event prompts", () => {
6363
name: "builds internal-only exec prompt when delivery is disabled",
6464
events: ["Exec failed (node=abc id=123, code 1)\nUpload failed"],
6565
opts: { deliverToUser: false },
66-
expected: ["user delivery is disabled", "Handle the result internally", "HEARTBEAT_OK only"],
67-
unexpected: [
68-
"Upload failed",
69-
"system messages above",
70-
"Please relay the command output to the user",
71-
],
66+
expected: ["User delivery is disabled", "Continue the task based on the result if needed"],
67+
unexpected: ["system messages above", "Please relay the command output to the user"],
7268
},
7369
{
7470
name: "suppresses empty exec completion prompts",
@@ -95,6 +91,17 @@ describe("heartbeat event prompts", () => {
9591
],
9692
unexpected: ["Please relay the command output to the user"],
9793
},
94+
{
95+
name: "shows failed exec without output when delivery is disabled",
96+
events: ["Exec failed (abc12345, code 1)"],
97+
opts: { deliverToUser: false },
98+
expected: [
99+
"without captured stdout/stderr",
100+
"Handle the result internally",
101+
"Continue the task based on the exit status",
102+
],
103+
unexpected: ["Please relay the command output to the user"],
104+
},
98105
])("$name", ({ events, opts, expected, unexpected }) => {
99106
const prompt = buildExecEventPrompt(events, opts);
100107
for (const part of expected) {

src/infra/heartbeat-events-filter.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,9 +146,20 @@ export function buildExecEventPrompt(
146146
"Do not mention, summarize, or reuse command output."
147147
);
148148
}
149+
if (hasMissingOutputFailure) {
150+
return (
151+
"An async command you ran earlier completed without captured stdout/stderr. The completion details are:\n\n" +
152+
eventText +
153+
"\n\n" +
154+
"User delivery is disabled for this run. Handle the result internally. Continue the task based on the exit status if applicable. " +
155+
"Do not ask the user to provide missing logs, and do not try to retrieve logs from an exec/session id."
156+
);
157+
}
149158
return (
150-
"An async command completion event was triggered, but user delivery is disabled for this run. " +
151-
"Handle the result internally and reply HEARTBEAT_OK only. Do not mention, summarize, or reuse command output."
159+
"An async command you ran earlier has completed. The completion details are:\n\n" +
160+
eventText +
161+
"\n\n" +
162+
"User delivery is disabled for this run. Handle the result internally. Continue the task based on the result if needed."
152163
);
153164
}
154165
if (hasMissingOutputFailure) {

src/infra/heartbeat-runner.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1240,6 +1240,8 @@ ${completionInstruction}`;
12401240
const baseUsesHeartbeatResponseTool = params.useHeartbeatResponseTool && !commitmentPrompt;
12411241
const basePrompt = hasExecCompletion
12421242
? buildExecEventPrompt(execEvents, {
1243+
// Exec details are always visible via transcriptBody. The prompt
1244+
// text is separate — deliverToUser controls relay instructions only.
12431245
deliverToUser: params.canRelayToUser,
12441246
useHeartbeatResponseTool: baseUsesHeartbeatResponseTool,
12451247
})

0 commit comments

Comments
 (0)