From 06c058b21df12a4c6d1bf70c5d8cd19268f71c49 Mon Sep 17 00:00:00 2001 From: stainlu Date: Mon, 20 Apr 2026 16:59:30 +0800 Subject: [PATCH] fix(agents): stop injecting heartbeat system prompt on non-heartbeat runs (#69079) --- .../run/trigger-policy.test.ts | 32 +++++++++++++++++++ .../pi-embedded-runner/run/trigger-policy.ts | 14 ++++++-- 2 files changed, 43 insertions(+), 3 deletions(-) create mode 100644 src/agents/pi-embedded-runner/run/trigger-policy.test.ts diff --git a/src/agents/pi-embedded-runner/run/trigger-policy.test.ts b/src/agents/pi-embedded-runner/run/trigger-policy.test.ts new file mode 100644 index 00000000000..fbbac2d7169 --- /dev/null +++ b/src/agents/pi-embedded-runner/run/trigger-policy.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { shouldInjectHeartbeatPromptForTrigger } from "./trigger-policy.js"; + +describe("shouldInjectHeartbeatPromptForTrigger", () => { + it("injects the heartbeat prompt on heartbeat-triggered runs", () => { + expect(shouldInjectHeartbeatPromptForTrigger("heartbeat")).toBe(true); + }); + + // Regression: the heartbeat system prompt instructs the model to reply + // exactly "HEARTBEAT_OK" when nothing is pending. If that prompt leaks into + // a user-triggered turn, the model can pattern-match the literal HEARTBEAT_OK + // token (which the delivery runtime then suppresses, so the user sees + // silence) or hallucinate a "[object Object]" serialization error as it + // tries to reconcile the heartbeat instruction with a real user message. + // See issue #69079 and its parent #50797. + it.each([ + ["user"] as const, + ["manual"] as const, + ["cron"] as const, + ["memory"] as const, + ["overflow"] as const, + ])("does not inject the heartbeat prompt on %s-triggered runs", (trigger) => { + expect(shouldInjectHeartbeatPromptForTrigger(trigger)).toBe(false); + }); + + it("does not inject the heartbeat prompt when no trigger is supplied", () => { + // Defense-in-depth: if a new call site lands without a trigger, it should + // fall through to the safe default rather than spuriously injecting + // heartbeat instructions. + expect(shouldInjectHeartbeatPromptForTrigger(undefined)).toBe(false); + }); +}); diff --git a/src/agents/pi-embedded-runner/run/trigger-policy.ts b/src/agents/pi-embedded-runner/run/trigger-policy.ts index 714a5dd9ced..4b7a18bb898 100644 --- a/src/agents/pi-embedded-runner/run/trigger-policy.ts +++ b/src/agents/pi-embedded-runner/run/trigger-policy.ts @@ -4,13 +4,21 @@ type EmbeddedRunTriggerPolicy = { injectHeartbeatPrompt: boolean; }; +// The heartbeat system prompt tells the model to reply exactly "HEARTBEAT_OK" +// when nothing needs attention. It is only meaningful on heartbeat-triggered +// runs; injecting it on user/manual/memory/overflow runs confuses smaller +// models into pattern-matching the HEARTBEAT_OK output on real user messages +// (delivery then suppresses the "reply", so the user sees silence) or into +// fabricating "[object Object]" serialization errors as they try to reconcile +// the heartbeat instruction with a non-heartbeat turn. See issue #69079 and +// its parent #50797. Default off; heartbeat trigger explicitly opts in. const DEFAULT_EMBEDDED_RUN_TRIGGER_POLICY: EmbeddedRunTriggerPolicy = { - injectHeartbeatPrompt: true, + injectHeartbeatPrompt: false, }; const EMBEDDED_RUN_TRIGGER_POLICY: Partial> = { - cron: { - injectHeartbeatPrompt: false, + heartbeat: { + injectHeartbeatPrompt: true, }, };