fix: filter heartbeat pairs before context shaping

This commit is contained in:
Ayaan Zaidi
2026-04-06 16:41:05 +05:30
parent 209786bb2d
commit c352fe8903
3 changed files with 73 additions and 11 deletions

View File

@@ -1,4 +1,7 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { HEARTBEAT_PROMPT } from "../../../auto-reply/heartbeat.js";
import { limitHistoryTurns } from "../history.js";
import {
cleanupTempPaths,
createContextEngineAttemptRunner,
@@ -130,4 +133,42 @@ describe("runEmbeddedAttempt context injection", () => {
expect.anything(),
);
});
it("filters no-op heartbeat pairs before history limiting and context-engine assembly", async () => {
hoisted.getDmHistoryLimitFromSessionKeyMock.mockReturnValue(1);
hoisted.limitHistoryTurnsMock.mockImplementation(limitHistoryTurns);
const assemble = vi.fn(async ({ messages }: { messages: AgentMessage[] }) => ({
messages,
estimatedTokens: 1,
}));
const sessionMessages: AgentMessage[] = [
{ role: "user", content: "real question", timestamp: 1 } as AgentMessage,
{ role: "assistant", content: "real answer", timestamp: 2 } as AgentMessage,
{ role: "user", content: HEARTBEAT_PROMPT, timestamp: 3 } as AgentMessage,
{ role: "assistant", content: "HEARTBEAT_OK", timestamp: 4 } as AgentMessage,
];
await createContextEngineAttemptRunner({
contextEngine: { assemble },
attemptOverrides: {
config: {
agents: {
list: [{ id: "main", heartbeat: {} }],
},
},
},
sessionKey: "agent:main:discord:dm:test-user",
sessionMessages,
tempPaths,
});
expect(assemble).toHaveBeenCalledWith(
expect.objectContaining({
messages: [
expect.objectContaining({ role: "user", content: "real question" }),
expect.objectContaining({ role: "assistant", content: "real answer" }),
],
}),
);
});
});

View File

@@ -53,6 +53,10 @@ type AttemptSpawnWorkspaceHoisted = {
getGlobalHookRunnerMock: Mock<() => unknown>;
initializeGlobalHookRunnerMock: UnknownMock;
runContextEngineMaintenanceMock: AsyncUnknownMock;
getDmHistoryLimitFromSessionKeyMock: Mock<
(sessionKey: string | undefined, config: unknown) => number | undefined
>;
limitHistoryTurnsMock: Mock<<T>(messages: T, limit: number | undefined) => T>;
sessionManager: SessionManagerMocks;
};
@@ -99,6 +103,12 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => {
const getGlobalHookRunnerMock = vi.fn<() => unknown>(() => undefined);
const initializeGlobalHookRunnerMock = vi.fn();
const runContextEngineMaintenanceMock = vi.fn(async (_params?: unknown) => undefined);
const getDmHistoryLimitFromSessionKeyMock = vi.fn<
(sessionKey: string | undefined, config: unknown) => number | undefined
>(() => undefined);
const limitHistoryTurnsMock = vi.fn<<T>(messages: T, limit: number | undefined) => T>(
(messages) => messages,
);
const sessionManager = {
getLeafEntry: vi.fn(() => null),
branch: vi.fn(),
@@ -124,6 +134,8 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => {
getGlobalHookRunnerMock,
initializeGlobalHookRunnerMock,
runContextEngineMaintenanceMock,
getDmHistoryLimitFromSessionKeyMock,
limitHistoryTurnsMock,
sessionManager,
};
});
@@ -430,8 +442,10 @@ vi.mock("../compaction-safety-timeout.js", () => ({
}));
vi.mock("../history.js", () => ({
getDmHistoryLimitFromSessionKey: () => undefined,
limitHistoryTurns: <T>(messages: T) => messages,
getDmHistoryLimitFromSessionKey: (sessionKey: string | undefined, config: unknown) =>
hoisted.getDmHistoryLimitFromSessionKeyMock(sessionKey, config),
limitHistoryTurns: <T>(messages: T, limit: number | undefined) =>
hoisted.limitHistoryTurnsMock(messages, limit),
}));
vi.mock("../logger.js", () => ({
@@ -599,6 +613,8 @@ export function resetEmbeddedAttemptHarness(
hoisted.hasCompletedBootstrapTurnMock.mockReset().mockResolvedValue(false);
hoisted.getGlobalHookRunnerMock.mockReset().mockReturnValue(undefined);
hoisted.runContextEngineMaintenanceMock.mockReset().mockResolvedValue(undefined);
hoisted.getDmHistoryLimitFromSessionKeyMock.mockReset().mockReturnValue(undefined);
hoisted.limitHistoryTurnsMock.mockReset().mockImplementation((messages) => messages);
hoisted.sessionManager.getLeafEntry.mockReset().mockReturnValue(null);
hoisted.sessionManager.branch.mockReset();
hoisted.sessionManager.resetLeaf.mockReset();
@@ -755,6 +771,7 @@ export async function createContextEngineAttemptRunner(params: {
info?: Partial<ContextEngineInfo>;
};
attemptOverrides?: Partial<Parameters<Awaited<ReturnType<typeof loadRunEmbeddedAttempt>>>[0]>;
sessionMessages?: AgentMessage[];
sessionKey: string;
tempPaths: string[];
}) {
@@ -764,9 +781,8 @@ export async function createContextEngineAttemptRunner(params: {
const sessionFile = path.join(workspaceDir, "session.jsonl");
params.tempPaths.push(workspaceDir, agentDir);
await fs.writeFile(sessionFile, "", "utf8");
const seedMessages: AgentMessage[] = [
{ role: "user", content: "seed", timestamp: 1 } as AgentMessage,
];
const seedMessages: AgentMessage[] =
params.sessionMessages ?? ([{ role: "user", content: "seed", timestamp: 1 }] as AgentMessage[]);
const infoId = params.contextEngine.info?.id ?? "test-context-engine";
const infoName = params.contextEngine.info?.name ?? "Test Context Engine";
const infoVersion = params.contextEngine.info?.version ?? "0.0.1";

View File

@@ -1217,8 +1217,17 @@ export async function runEmbeddedAttempt(
sessionId: params.sessionId,
policy: transcriptPolicy,
});
const truncated = limitHistoryTurns(
const heartbeatSummary =
params.config && sessionAgentId
? resolveHeartbeatSummaryForAgent(params.config, sessionAgentId)
: undefined;
const heartbeatFiltered = filterHeartbeatPairs(
validated,
heartbeatSummary?.ackMaxChars,
heartbeatSummary?.prompt,
);
const truncated = limitHistoryTurns(
heartbeatFiltered,
getDmHistoryLimitFromSessionKey(params.sessionKey, params.config),
);
// Re-run tool_use/tool_result pairing repair after truncation, since
@@ -1667,10 +1676,6 @@ export async function runEmbeddedAttempt(
activeSession.agent.state.messages = activeSession.messages;
}
const heartbeatSummary =
params.config && sessionAgentId
? resolveHeartbeatSummaryForAgent(params.config, sessionAgentId)
: undefined;
const filteredMessages = filterHeartbeatPairs(
activeSession.messages,
heartbeatSummary?.ackMaxChars,