feat(FN-4244): add no-task heartbeat procedure override

Merged FN-4244: adds an override path for the no-task heartbeat procedure in the engine, allowing agents to execute a custom procedure when they have no active task. The fix commit corrects the initial implementation, with test coverage for procedure source selection and updated documentation in `do

Fusion-Task-Id: FN-4244

Fusion-Task-Lineage: 6bf1bfe4-de2c-4467-93dc-d90231a53eb2
This commit is contained in:
Fusion
2026-05-12 21:34:41 -07:00
committed by gsxdsm
parent 55eeb818a5
commit ac59d5c71d
7 changed files with 88 additions and 7 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Override task-scoped heartbeat procedure files during no-task heartbeats so ambient runs only receive tool-safe prompt guidance.

View File

@@ -763,7 +763,7 @@ When the bound task is `executor-class` or `blocked`, the default procedure dire
The manager-facing reports health block in that prompt is populated from `AgentStore.getAgentsByReportsTo(agent.id)`. Engine code must call that store method with its `AgentStore` instance binding intact because some implementations resolve direct reports through `this.listAgents()`. If the section disappears unexpectedly, look for logs like `Failed to load reports ... Cannot read properties of undefined (reading 'listAgents')`, which indicate an unbound method call regressed.
This behavior is inherited by new non-ephemeral agents because agent creation seeds a per-agent `HEARTBEAT.md` file from the built-in default. If an agent sets `heartbeatProcedurePath`, that markdown file fully replaces the built-in default at runtime.
This behavior is inherited by new non-ephemeral agents because agent creation seeds a per-agent `HEARTBEAT.md` file from the built-in default. If an agent sets `heartbeatProcedurePath`, that markdown file fully replaces the built-in default at runtime for task-scoped heartbeats. No-task heartbeats always fall back to the ambient built-in procedure so the prompt never references task-only tools.
For pre-existing agents, use `POST /api/agents/:id/upgrade-heartbeat-procedure` (also exposed as **Upgrade to Default Heartbeat Procedure** in the agent detail Config tab) to re-seed from the current built-in constant. When the built-in default changes, running this upgrade propagates the new default to existing agents; direct operator edits to an agents existing procedure file are preserved unless this upgrade is run (the upgrade overwrites the per-agent file).

View File

@@ -49,7 +49,7 @@ describe("TaskStore", () => {
} finally {
harness.store().stopWatching();
}
}, 30_000);
}, 60_000);
it("cache is updated when polling is active even without fs.watch", async () => {
await harness.store().watch();

View File

@@ -4057,8 +4057,8 @@ export interface AgentHeartbeatRun {
systemPrompt?: string;
/** Full per-tick execution prompt sent to the LLM for this run (truncated to 100,000 chars). */
executionPrompt?: string;
/** Whether a custom heartbeat procedure was loaded ("custom") or the built-in default was used ("default"). */
heartbeatProcedureSource?: "default" | "custom";
/** Whether the run used a custom heartbeat procedure, the built-in default, or the no-task default override. */
heartbeatProcedureSource?: "default" | "custom" | "default-no-task-override";
}
/** Capabilities/roles an agent can have */

View File

@@ -936,6 +936,7 @@ export function QuickChatFAB({
// Track if we just finished a drag (to prevent click from firing after drag)
const didDragRef = useRef(false);
const modelsRequestedRef = useRef(false);
const modelsInitSettledRef = useRef(false);
const prevSessionTargetRef = useRef("");
const hasAppliedInitialSessionRef = useRef(false);
const selectedAgentIdRef = useRef(selectedAgentId);
@@ -1149,6 +1150,7 @@ export function QuickChatFAB({
}
modelsRequestedRef.current = true;
modelsInitSettledRef.current = false;
setModelsLoading(true);
fetchModels()
@@ -1199,6 +1201,7 @@ export function QuickChatFAB({
setConfiguredDefaultModelSelection("");
})
.finally(() => {
modelsInitSettledRef.current = true;
setModelsLoading(false);
});
}, [isOpen, agents.length, selectedModel]);
@@ -1258,6 +1261,14 @@ export function QuickChatFAB({
return;
}
const waitingForInitialModelResolution = !hasAppliedInitialSessionRef.current
&& sessions.length === 0
&& modelsRequestedRef.current
&& !modelsInitSettledRef.current;
if (waitingForInitialModelResolution) {
return;
}
if (!sessionTargetKey) {
prevSessionTargetRef.current = "";
return;

View File

@@ -855,6 +855,57 @@ describe("executeHeartbeat", () => {
expect(executionPrompt).toContain(HEARTBEAT_NO_TASK_PROCEDURE);
});
it("no-task run overrides a seeded task-scoped heartbeatProcedurePath in the assembled prompt", async () => {
const tmpRoot = mkdtempSync(join(tmpdir(), "fn-hb-no-task-procedure-"));
try {
writeFileSync(join(tmpRoot, "HEARTBEAT.md"), HEARTBEAT_PROCEDURE, "utf-8");
const store = createStoreWithAgentForExec({
taskId: undefined,
soul: "I am a coordinator",
heartbeatProcedurePath: "HEARTBEAT.md",
});
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: tmpRoot });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
expect(result.status).toBe("completed");
const executionPrompt = mockSession.prompt.mock.calls.at(-1)?.[0];
expect(executionPrompt).toBeDefined();
expect(executionPrompt).not.toContain("fn_task_log");
expect(executionPrompt).not.toContain("fn_task_document_write");
expect(executionPrompt).not.toContain("do not re-read PROMPT.md to advance it");
expect(executionPrompt).toContain("Implementation-scope discovery");
const savedRun = await store.getRunDetail("agent-001", result.id);
expect(savedRun?.heartbeatProcedureSource).toBe("default-no-task-override");
} finally {
rmSync(tmpRoot, { recursive: true, force: true });
}
});
it("no-task run without a custom heartbeatProcedurePath still uses the ambient procedure", async () => {
const store = createStoreWithAgentForExec({ taskId: undefined, soul: "I am a coordinator" });
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
expect(result.status).toBe("completed");
const executionPrompt = mockSession.prompt.mock.calls.at(-1)?.[0];
expect(executionPrompt).toBeDefined();
expect(executionPrompt).not.toContain("fn_task_log");
expect(executionPrompt).not.toContain("fn_task_document_write");
expect(executionPrompt).not.toContain("do not re-read PROMPT.md to advance it");
expect(executionPrompt).toContain("Implementation-scope discovery");
const savedRun = await store.getRunDetail("agent-001", result.id);
expect(savedRun?.heartbeatProcedureSource).toBe("default");
});
it("task-scoped run receives HEARTBEAT_SYSTEM_PROMPT as system prompt", async () => {
const store = createStoreWithAgentForExec({ taskId: "FN-001" });
const mockSession = createMockAgentSession();
@@ -1305,6 +1356,9 @@ describe("executeHeartbeat", () => {
expect(executionPrompt).not.toContain(HEARTBEAT_PROCEDURE);
// Wake Delta still rendered.
expect(executionPrompt).toContain("## Wake Delta");
const savedRun = await store.getRunDetail("agent-001", result.id);
expect(savedRun?.heartbeatProcedureSource).toBe("custom");
} finally {
rmSync(tmpRoot, { recursive: true, force: true });
}

View File

@@ -2163,8 +2163,19 @@ export class HeartbeatMonitor {
// existing instructionsPath/instructionsText reload contract) so an
// operator can iterate on procedure text without restarting agents.
const customProcedure = await resolveAgentHeartbeatProcedure(agent, rootDir);
const heartbeatProcedureText = customProcedure
?? (isNoTaskRun ? HEARTBEAT_NO_TASK_PROCEDURE : HEARTBEAT_PROCEDURE);
const customProcedureConfigured = Boolean(customProcedure);
const shouldOverrideCustomProcedureForNoTaskRun = isNoTaskRun && customProcedureConfigured;
if (shouldOverrideCustomProcedureForNoTaskRun) {
heartbeatLog.log(
`Agent ${agentId} no-task heartbeat bypassed configured heartbeatProcedurePath and used HEARTBEAT_NO_TASK_PROCEDURE to keep prompt guidance aligned with ambient tools`,
);
}
const heartbeatProcedureText = shouldOverrideCustomProcedureForNoTaskRun
? HEARTBEAT_NO_TASK_PROCEDURE
: (customProcedure ?? (isNoTaskRun ? HEARTBEAT_NO_TASK_PROCEDURE : HEARTBEAT_PROCEDURE));
const heartbeatProcedureSource = shouldOverrideCustomProcedureForNoTaskRun
? "default-no-task-override"
: (customProcedure ? "custom" : "default");
const reportsHealthSection = await this.buildReportsHealthSection(agent.id, this.store);
if (isNoTaskRun) {
@@ -2360,7 +2371,7 @@ export class HeartbeatMonitor {
...run,
systemPrompt: truncatePrompt(systemPromptFinal, 100_000),
executionPrompt: truncatePrompt(executionPrompt, 100_000),
heartbeatProcedureSource: customProcedure ? "custom" : "default",
heartbeatProcedureSource,
};
await this.store.saveRun(runWithPrompts);
// Update local run reference so completeRun merges correctly