From 349749f73dee8023724a4954a8cea9de891ba95e Mon Sep 17 00:00:00 2001 From: GodsBoy Date: Fri, 24 Apr 2026 20:37:55 +0200 Subject: [PATCH] fix(heartbeat): include exec completion payloads --- src/infra/heartbeat-events-filter.test.ts | 37 +++++++++++++++++++---- src/infra/heartbeat-events-filter.ts | 26 +++++++++++++--- src/infra/heartbeat-runner.ts | 12 +++++--- 3 files changed, 60 insertions(+), 15 deletions(-) diff --git a/src/infra/heartbeat-events-filter.test.ts b/src/infra/heartbeat-events-filter.test.ts index 761dbf88f45..153f85f83ba 100644 --- a/src/infra/heartbeat-events-filter.test.ts +++ b/src/infra/heartbeat-events-filter.test.ts @@ -47,18 +47,36 @@ describe("heartbeat event prompts", () => { it.each([ { name: "builds user-relay exec prompt by default", + events: ["Exec finished (node=abc id=123, code 0)\nUploaded file"], opts: undefined, - expected: ["Please relay the command output to the user", "If it failed"], - unexpected: ["Handle the result internally"], + expected: [ + "Exec finished", + "Uploaded file", + "Please relay the command output to the user", + "If it failed", + ], + unexpected: ["system messages above", "Handle the result internally"], }, { name: "builds internal-only exec prompt when delivery is disabled", + events: ["Exec failed (node=abc id=123, code 1)\nUpload failed"], opts: { deliverToUser: false }, - expected: ["Handle the result internally"], - unexpected: ["Please relay the command output to the user"], + expected: ["user delivery is disabled", "Handle the result internally", "HEARTBEAT_OK only"], + unexpected: [ + "Upload failed", + "system messages above", + "Please relay the command output to the user", + ], }, - ])("$name", ({ opts, expected, unexpected }) => { - const prompt = buildExecEventPrompt(opts); + { + name: "suppresses empty exec completion prompts", + events: ["", " "], + opts: undefined, + expected: ["no command output was found", "Reply HEARTBEAT_OK only"], + unexpected: ["Please relay the command output to the user", "system messages above"], + }, + ])("$name", ({ events, opts, expected, unexpected }) => { + const prompt = buildExecEventPrompt(events, opts); for (const part of expected) { expect(prompt).toContain(part); } @@ -66,6 +84,13 @@ describe("heartbeat event prompts", () => { expect(prompt).not.toContain(part); } }); + + it("truncates oversized user-relay exec prompt output", () => { + const prompt = buildExecEventPrompt([`Exec finished: ${"x".repeat(8_100)}`]); + + expect(prompt).toContain("[truncated]"); + expect(prompt.length).toBeLessThan(8_500); + }); }); describe("heartbeat event classification", () => { diff --git a/src/infra/heartbeat-events-filter.ts b/src/infra/heartbeat-events-filter.ts index 8c540fadf80..5a73004baab 100644 --- a/src/infra/heartbeat-events-filter.ts +++ b/src/infra/heartbeat-events-filter.ts @@ -1,6 +1,8 @@ import { HEARTBEAT_TOKEN } from "../auto-reply/tokens.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +const MAX_EXEC_EVENT_PROMPT_CHARS = 8_000; + // Build a dynamic prompt for cron events by embedding the actual event content. // This ensures the model sees the reminder text directly instead of relying on // "shown in the system messages above" which may not be visible in context. @@ -38,16 +40,32 @@ export function buildCronEventPrompt( ); } -export function buildExecEventPrompt(opts?: { deliverToUser?: boolean }): string { +export function buildExecEventPrompt( + pendingEvents: string[], + opts?: { deliverToUser?: boolean }, +): string { const deliverToUser = opts?.deliverToUser ?? true; + const rawEventText = pendingEvents.join("\n").trim(); + const eventText = + rawEventText.length > MAX_EXEC_EVENT_PROMPT_CHARS + ? `${rawEventText.slice(0, MAX_EXEC_EVENT_PROMPT_CHARS)}\n\n[truncated]` + : rawEventText; + 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 you ran earlier has completed. The result is shown in the system messages above. " + - "Handle the result internally. Do not relay it to the user unless explicitly requested." + "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." ); } return ( - "An async command you ran earlier has completed. The result is shown in the system messages above. " + + "An async command you ran earlier has completed. The command completion details are:\n\n" + + eventText + + "\n\n" + "Please relay the command output to the user in a helpful way. If the command succeeded, share the relevant output. " + "If it failed, explain what went wrong." ); diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 4a4ca1b0493..74350d4abdb 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -667,9 +667,6 @@ function resolveHeartbeatRunPrompt(params: { heartbeatFileContent?: string; }): HeartbeatPromptResolution { const pendingEventEntries = params.preflight.pendingEventEntries; - const pendingEvents = params.preflight.shouldInspectPendingEvents - ? pendingEventEntries.map((event) => event.text) - : []; const cronEvents = pendingEventEntries .filter( (event) => @@ -677,7 +674,12 @@ function resolveHeartbeatRunPrompt(params: { isCronSystemEvent(event.text), ) .map((event) => event.text); - const hasExecCompletion = pendingEvents.some(isExecCompletionEvent); + const execEvents = params.preflight.shouldInspectPendingEvents + ? pendingEventEntries + .filter((event) => event.trusted !== false && isExecCompletionEvent(event.text)) + .map((event) => event.text) + : []; + const hasExecCompletion = execEvents.length > 0; const hasCronEvents = cronEvents.length > 0; // If tasks are defined, build a batched prompt with due tasks @@ -716,7 +718,7 @@ After completing all due tasks, reply HEARTBEAT_OK.`; // Fallback to original behavior const basePrompt = hasExecCompletion - ? buildExecEventPrompt({ deliverToUser: params.canRelayToUser }) + ? buildExecEventPrompt(execEvents, { deliverToUser: params.canRelayToUser }) : hasCronEvents ? buildCronEventPrompt(cronEvents, { deliverToUser: params.canRelayToUser }) : resolveHeartbeatPrompt(params.cfg, params.heartbeat);