Skip to content

Commit cf86a97

Browse files
azoghebclaudevincentkoc
authored
fix(agents): run heartbeat_prompt_contribution on harness prompt builds (#96233)
* fix(agents): run heartbeat_prompt_contribution on harness prompt builds Harness runtimes (e.g. the Codex app-server) assemble the prompt through resolveAgentHarnessBeforePromptBuildResult rather than the embedded runner's resolvePromptBuildHookResult. The harness helper ran before_prompt_build and before_agent_start but never invoked heartbeat_prompt_contribution, so that hook silently no-ops on those runtimes: plugins that contribute heartbeat context via the documented hook get nothing on heartbeat turns. Invoke heartbeat_prompt_contribution from the harness helper too, gated on ctx.trigger === "heartbeat", merging its prepend/append context ahead of the before_prompt_build / before_agent_start contributions (matching the embedded path's ordering). before_prompt_build appendContext is already honored here, so no change is needed for boot-style append contributions. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> * fix(agents): preserve heartbeat hook ordering --------- Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]> Co-authored-by: Vincent Koc <[email protected]>
1 parent 0671c08 commit cf86a97

2 files changed

Lines changed: 115 additions & 2 deletions

File tree

src/agents/harness/prompt-compaction-hook-helpers.test.ts

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1-
import { afterEach, describe, expect, it } from "vitest";
2-
import { resetGlobalHookRunner } from "../../plugins/hook-runner-global.js";
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import {
3+
initializeGlobalHookRunner,
4+
resetGlobalHookRunner,
5+
} from "../../plugins/hook-runner-global.js";
6+
import { createMockPluginRegistry } from "../../plugins/hooks.test-helpers.js";
37
import { resolveAgentHarnessBeforePromptBuildResult } from "./prompt-compaction-hook-helpers.js";
48

59
afterEach(() => {
@@ -63,4 +67,84 @@ describe("resolveAgentHarnessBeforePromptBuildResult", () => {
6367
promptInputRange: { start: 17, end: 17 },
6468
});
6569
});
70+
71+
it("runs heartbeat_prompt_contribution on a heartbeat turn and prepends its contribution", async () => {
72+
initializeGlobalHookRunner(
73+
createMockPluginRegistry([
74+
{
75+
hookName: "heartbeat_prompt_contribution",
76+
handler: () => ({ prependContext: "Run the base-heartbeat skill." }),
77+
},
78+
]),
79+
);
80+
81+
const result = await resolveAgentHarnessBeforePromptBuildResult({
82+
prompt: "Read HEARTBEAT.md.",
83+
developerInstructions: "base instructions",
84+
messages: [],
85+
ctx: { trigger: "heartbeat", agentId: "agent-1", sessionKey: "session-1" },
86+
});
87+
88+
expect(result.prompt).toBe("Run the base-heartbeat skill.\n\nRead HEARTBEAT.md.");
89+
// The heartbeat contribution affects only the prompt, not developer instructions.
90+
expect(result.developerInstructions).toBe("base instructions");
91+
});
92+
93+
it("runs heartbeat contributions before other prompt-build hooks", async () => {
94+
const calls: string[] = [];
95+
initializeGlobalHookRunner(
96+
createMockPluginRegistry([
97+
{
98+
hookName: "heartbeat_prompt_contribution",
99+
handler: () => {
100+
calls.push("heartbeat");
101+
return { prependContext: "heartbeat context" };
102+
},
103+
},
104+
{
105+
hookName: "before_prompt_build",
106+
handler: () => {
107+
calls.push("before_prompt_build");
108+
return { prependContext: "prompt context" };
109+
},
110+
},
111+
{
112+
hookName: "before_agent_start",
113+
handler: () => {
114+
calls.push("before_agent_start");
115+
return { prependContext: "agent-start context" };
116+
},
117+
},
118+
]),
119+
);
120+
121+
const result = await resolveAgentHarnessBeforePromptBuildResult({
122+
prompt: "hello",
123+
developerInstructions: "base instructions",
124+
messages: [],
125+
ctx: { trigger: "heartbeat", agentId: "agent-1", sessionKey: "session-1" },
126+
});
127+
128+
expect(calls).toEqual(["heartbeat", "before_prompt_build", "before_agent_start"]);
129+
expect(result.prompt).toBe(
130+
"heartbeat context\n\nprompt context\n\nagent-start context\n\nhello",
131+
);
132+
});
133+
134+
it("skips heartbeat_prompt_contribution off a heartbeat turn", async () => {
135+
const handler = vi.fn(() => ({ prependContext: "should not appear" }));
136+
initializeGlobalHookRunner(
137+
createMockPluginRegistry([{ hookName: "heartbeat_prompt_contribution", handler }]),
138+
);
139+
140+
const result = await resolveAgentHarnessBeforePromptBuildResult({
141+
prompt: "hello",
142+
developerInstructions: "base instructions",
143+
messages: [],
144+
ctx: { trigger: "user", agentId: "agent-1", sessionKey: "session-1" },
145+
});
146+
147+
expect(handler).not.toHaveBeenCalled();
148+
expect(result.prompt).toBe("hello");
149+
});
66150
});

src/agents/harness/prompt-compaction-hook-helpers.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,16 @@ export async function resolveAgentHarnessBeforePromptBuildResult(params: {
3535
}): Promise<AgentHarnessPromptBuildResult> {
3636
const hookRunner = getGlobalHookRunner();
3737
const hasPrecomputedBeforeAgentStartResult = "beforeAgentStartResult" in params;
38+
// heartbeat_prompt_contribution fires only on heartbeat turns. Harness runtimes
39+
// (e.g. the Codex app-server) build the prompt through this helper rather than
40+
// the embedded runner's resolvePromptBuildHookResult, so the hook must run from
41+
// here too — otherwise it never fires on those runtimes.
42+
const isHeartbeatTurn = params.ctx.trigger === "heartbeat";
43+
const hasHeartbeatContribution =
44+
isHeartbeatTurn && Boolean(hookRunner?.hasHooks("heartbeat_prompt_contribution"));
3845
if (
3946
!hasPrecomputedBeforeAgentStartResult &&
47+
!hasHeartbeatContribution &&
4048
!hookRunner?.hasHooks("before_prompt_build") &&
4149
!hookRunner?.hasHooks("before_agent_start")
4250
) {
@@ -52,6 +60,25 @@ export async function resolveAgentHarnessBeforePromptBuildResult(params: {
5260
messages: params.messages,
5361
};
5462

63+
// Match the embedded runner's lifecycle order: heartbeat contributions are
64+
// collected before prompt-build hooks so hook side effects stay deterministic.
65+
const heartbeatResult =
66+
hasHeartbeatContribution && hookRunner
67+
? await hookRunner
68+
.runHeartbeatPromptContribution(
69+
{
70+
sessionKey: params.ctx.sessionKey,
71+
agentId: params.ctx.agentId,
72+
heartbeatName: "heartbeat",
73+
},
74+
hookCtx,
75+
)
76+
.catch((error: unknown) => {
77+
log.warn(`heartbeat_prompt_contribution hook failed: ${String(error)}`);
78+
return undefined;
79+
})
80+
: undefined;
81+
5582
// Support the newer before_prompt_build hook plus the deprecated
5683
// before_agent_start hook during the prompt-build migration window.
5784
const promptBuildResult = hookRunner?.hasHooks("before_prompt_build")
@@ -79,10 +106,12 @@ export async function resolveAgentHarnessBeforePromptBuildResult(params: {
79106
beforeAgentStartResult,
80107
});
81108
const promptPrefix = joinPresentTextSegments([
109+
heartbeatResult?.prependContext,
82110
promptBuildResult?.prependContext,
83111
beforeAgentStartResult?.prependContext,
84112
]);
85113
const promptSuffix = joinPresentTextSegments([
114+
heartbeatResult?.appendContext,
86115
promptBuildResult?.appendContext,
87116
beforeAgentStartResult?.appendContext,
88117
]);

0 commit comments

Comments
 (0)