fix(heartbeat): include exec completion payloads

This commit is contained in:
GodsBoy
2026-04-24 20:37:55 +02:00
committed by Ayaan Zaidi
parent 7e52223d32
commit 349749f73d
3 changed files with 60 additions and 15 deletions

View File

@@ -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", () => {

View File

@@ -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."
);

View File

@@ -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);