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:
5
.changeset/fn-4244-no-task-heartbeat-procedure.md
Normal file
5
.changeset/fn-4244-no-task-heartbeat-procedure.md
Normal 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.
|
||||
@@ -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 agent’s existing procedure file are preserved unless this upgrade is run (the upgrade overwrites the per-agent 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();
|
||||
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user