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.
|
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).
|
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 {
|
} finally {
|
||||||
harness.store().stopWatching();
|
harness.store().stopWatching();
|
||||||
}
|
}
|
||||||
}, 30_000);
|
}, 60_000);
|
||||||
it("cache is updated when polling is active even without fs.watch", async () => {
|
it("cache is updated when polling is active even without fs.watch", async () => {
|
||||||
await harness.store().watch();
|
await harness.store().watch();
|
||||||
|
|
||||||
|
|||||||
@@ -4057,8 +4057,8 @@ export interface AgentHeartbeatRun {
|
|||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
/** Full per-tick execution prompt sent to the LLM for this run (truncated to 100,000 chars). */
|
/** Full per-tick execution prompt sent to the LLM for this run (truncated to 100,000 chars). */
|
||||||
executionPrompt?: string;
|
executionPrompt?: string;
|
||||||
/** Whether a custom heartbeat procedure was loaded ("custom") or the built-in default was used ("default"). */
|
/** Whether the run used a custom heartbeat procedure, the built-in default, or the no-task default override. */
|
||||||
heartbeatProcedureSource?: "default" | "custom";
|
heartbeatProcedureSource?: "default" | "custom" | "default-no-task-override";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Capabilities/roles an agent can have */
|
/** 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)
|
// Track if we just finished a drag (to prevent click from firing after drag)
|
||||||
const didDragRef = useRef(false);
|
const didDragRef = useRef(false);
|
||||||
const modelsRequestedRef = useRef(false);
|
const modelsRequestedRef = useRef(false);
|
||||||
|
const modelsInitSettledRef = useRef(false);
|
||||||
const prevSessionTargetRef = useRef("");
|
const prevSessionTargetRef = useRef("");
|
||||||
const hasAppliedInitialSessionRef = useRef(false);
|
const hasAppliedInitialSessionRef = useRef(false);
|
||||||
const selectedAgentIdRef = useRef(selectedAgentId);
|
const selectedAgentIdRef = useRef(selectedAgentId);
|
||||||
@@ -1149,6 +1150,7 @@ export function QuickChatFAB({
|
|||||||
}
|
}
|
||||||
|
|
||||||
modelsRequestedRef.current = true;
|
modelsRequestedRef.current = true;
|
||||||
|
modelsInitSettledRef.current = false;
|
||||||
setModelsLoading(true);
|
setModelsLoading(true);
|
||||||
|
|
||||||
fetchModels()
|
fetchModels()
|
||||||
@@ -1199,6 +1201,7 @@ export function QuickChatFAB({
|
|||||||
setConfiguredDefaultModelSelection("");
|
setConfiguredDefaultModelSelection("");
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
modelsInitSettledRef.current = true;
|
||||||
setModelsLoading(false);
|
setModelsLoading(false);
|
||||||
});
|
});
|
||||||
}, [isOpen, agents.length, selectedModel]);
|
}, [isOpen, agents.length, selectedModel]);
|
||||||
@@ -1258,6 +1261,14 @@ export function QuickChatFAB({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const waitingForInitialModelResolution = !hasAppliedInitialSessionRef.current
|
||||||
|
&& sessions.length === 0
|
||||||
|
&& modelsRequestedRef.current
|
||||||
|
&& !modelsInitSettledRef.current;
|
||||||
|
if (waitingForInitialModelResolution) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!sessionTargetKey) {
|
if (!sessionTargetKey) {
|
||||||
prevSessionTargetRef.current = "";
|
prevSessionTargetRef.current = "";
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -855,6 +855,57 @@ describe("executeHeartbeat", () => {
|
|||||||
expect(executionPrompt).toContain(HEARTBEAT_NO_TASK_PROCEDURE);
|
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 () => {
|
it("task-scoped run receives HEARTBEAT_SYSTEM_PROMPT as system prompt", async () => {
|
||||||
const store = createStoreWithAgentForExec({ taskId: "FN-001" });
|
const store = createStoreWithAgentForExec({ taskId: "FN-001" });
|
||||||
const mockSession = createMockAgentSession();
|
const mockSession = createMockAgentSession();
|
||||||
@@ -1305,6 +1356,9 @@ describe("executeHeartbeat", () => {
|
|||||||
expect(executionPrompt).not.toContain(HEARTBEAT_PROCEDURE);
|
expect(executionPrompt).not.toContain(HEARTBEAT_PROCEDURE);
|
||||||
// Wake Delta still rendered.
|
// Wake Delta still rendered.
|
||||||
expect(executionPrompt).toContain("## Wake Delta");
|
expect(executionPrompt).toContain("## Wake Delta");
|
||||||
|
|
||||||
|
const savedRun = await store.getRunDetail("agent-001", result.id);
|
||||||
|
expect(savedRun?.heartbeatProcedureSource).toBe("custom");
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(tmpRoot, { recursive: true, force: true });
|
rmSync(tmpRoot, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2163,8 +2163,19 @@ export class HeartbeatMonitor {
|
|||||||
// existing instructionsPath/instructionsText reload contract) so an
|
// existing instructionsPath/instructionsText reload contract) so an
|
||||||
// operator can iterate on procedure text without restarting agents.
|
// operator can iterate on procedure text without restarting agents.
|
||||||
const customProcedure = await resolveAgentHeartbeatProcedure(agent, rootDir);
|
const customProcedure = await resolveAgentHeartbeatProcedure(agent, rootDir);
|
||||||
const heartbeatProcedureText = customProcedure
|
const customProcedureConfigured = Boolean(customProcedure);
|
||||||
?? (isNoTaskRun ? HEARTBEAT_NO_TASK_PROCEDURE : HEARTBEAT_PROCEDURE);
|
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);
|
const reportsHealthSection = await this.buildReportsHealthSection(agent.id, this.store);
|
||||||
|
|
||||||
if (isNoTaskRun) {
|
if (isNoTaskRun) {
|
||||||
@@ -2360,7 +2371,7 @@ export class HeartbeatMonitor {
|
|||||||
...run,
|
...run,
|
||||||
systemPrompt: truncatePrompt(systemPromptFinal, 100_000),
|
systemPrompt: truncatePrompt(systemPromptFinal, 100_000),
|
||||||
executionPrompt: truncatePrompt(executionPrompt, 100_000),
|
executionPrompt: truncatePrompt(executionPrompt, 100_000),
|
||||||
heartbeatProcedureSource: customProcedure ? "custom" : "default",
|
heartbeatProcedureSource,
|
||||||
};
|
};
|
||||||
await this.store.saveRun(runWithPrompts);
|
await this.store.saveRun(runWithPrompts);
|
||||||
// Update local run reference so completeRun merges correctly
|
// Update local run reference so completeRun merges correctly
|
||||||
|
|||||||
Reference in New Issue
Block a user