fix(guardian): detect system triggers from historyMessages, not just currentPrompt

Heartbeat prompts may arrive via historyMessages (as the last user message)
rather than via currentPrompt, depending on the agent loop stage. Check both
sources for system trigger detection so heartbeat tool calls are consistently
skipped regardless of how the prompt is delivered.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
ShengtongZhu 2026-03-15 12:00:30 +08:00
parent 13b4a0bbeb
commit 8a2c15f9bc
2 changed files with 48 additions and 13 deletions

View File

@ -556,12 +556,12 @@ describe("message-cache", () => {
expect(isSystemTrigger("nonexistent")).toBe(false);
});
it("preserves isSystemTrigger when subsequent llm_input has no prompt", () => {
it("stays true when heartbeat is in historyMessages on subsequent llm_input", () => {
// Heartbeat fires with prompt → isSystemTrigger=true
updateCache("s1", [], "heartbeat", 3, NO_FILTER);
expect(isSystemTrigger("s1")).toBe(true);
// Agent loop continues without prompt (tool result processed) → should preserve true
// Agent loop continues — heartbeat is now in historyMessages
updateCache("s1", [{ role: "user", content: "heartbeat" }], undefined, 3, NO_FILTER);
expect(isSystemTrigger("s1")).toBe(true);
});
@ -570,11 +570,15 @@ describe("message-cache", () => {
updateCache("s1", [], "heartbeat", 3, NO_FILTER);
expect(isSystemTrigger("s1")).toBe(true);
// Real user message arrives → should reset to false
// Real user message arrives — now the last user message in history is the real one
updateCache(
"s1",
[{ role: "user", content: "heartbeat" }],
"Deploy my project",
[
{ role: "user", content: "heartbeat" },
{ role: "assistant", content: "HEARTBEAT_OK" },
{ role: "user", content: "Deploy my project" },
],
undefined,
3,
NO_FILTER,
);
@ -586,6 +590,27 @@ describe("message-cache", () => {
updateCache("s1", [], undefined, 3, NO_FILTER);
expect(isSystemTrigger("s1")).toBe(false);
});
it("detects heartbeat from last user message in historyMessages when currentPrompt is undefined", () => {
// Heartbeat prompt arrives via historyMessages, not currentPrompt
const heartbeatPrompt =
"Read HEARTBEAT.md if it exists (workspace context). If nothing needs attention, reply HEARTBEAT_OK.";
updateCache("s1", [{ role: "user", content: heartbeatPrompt }], undefined, 3, NO_FILTER);
expect(isSystemTrigger("s1")).toBe(true);
});
it("detects heartbeat from historyMessages even on first llm_input (no existing entry)", () => {
updateCache("s1", [{ role: "user", content: "heartbeat" }], undefined, 3, NO_FILTER);
expect(isSystemTrigger("s1")).toBe(true);
});
it("resets when historyMessages last user message is not a system trigger", () => {
updateCache("s1", [{ role: "user", content: "heartbeat" }], undefined, 3, NO_FILTER);
expect(isSystemTrigger("s1")).toBe(true);
updateCache("s1", [{ role: "user", content: "Deploy my project" }], undefined, 3, NO_FILTER);
expect(isSystemTrigger("s1")).toBe(false);
});
});
describe("getRecentTurns filters system turns", () => {

View File

@ -49,14 +49,13 @@ export function updateCache(
contextTools,
totalTurnsProcessed: totalTurns,
lastSummarizedTurnCount: existing?.lastSummarizedTurnCount ?? 0,
// Preserve isSystemTrigger when currentPrompt is empty (agent loop continuation).
// During a heartbeat cycle, llm_input fires multiple times: first with the
// heartbeat prompt (isSystemTrigger=true), then without a prompt as the agent
// loop continues after tool results. Without preservation, the flag resets to
// false and heartbeat tool calls reach the guardian unnecessarily.
isSystemTrigger: currentPrompt
? isSystemTriggerPrompt(currentPrompt)
: (existing?.isSystemTrigger ?? false),
// Detect system triggers from both currentPrompt AND the last user message
// in historyMessages. Heartbeats may arrive via either path depending on
// the agent loop stage (currentPrompt on first llm_input, historyMessages
// on subsequent continuations after tool results).
isSystemTrigger:
isSystemTriggerPrompt(currentPrompt) ||
isSystemTriggerPrompt(getLastUserMessageText(historyMessages)),
agentSystemPrompt: existing?.agentSystemPrompt,
updatedAt: Date.now(),
});
@ -279,6 +278,17 @@ function filterSystemTurns(turns: ConversationTurn[]): ConversationTurn[] {
});
}
/** Extract text from the last user message in the history array. */
function getLastUserMessageText(historyMessages: unknown[]): string | undefined {
for (let i = historyMessages.length - 1; i >= 0; i--) {
const msg = historyMessages[i];
if (isMessageLike(msg) && msg.role === "user") {
return extractTextContent(msg.content) || undefined;
}
}
return undefined;
}
/** Count user messages in the history array. */
function countUserMessages(historyMessages: unknown[]): number {
let count = 0;