feat(FN-3036): fix heartbeat prompt composition for autonomous agents

This merge fixes heartbeat prompt composition for autonomous agents (FN-3036) so child agents spawned via `spawn_agent` receive properly formatted system prompts with heartbeat instructions, adds regression tests and documentation for the behavior, and removes duplicate constructor assignments in He

Fusion-Task-Id: FN-3036
This commit is contained in:
Fusion
2026-05-01 12:04:35 -07:00
committed by gsxdsm
parent 31bc668c20
commit 1c3fec3bd6
4 changed files with 149 additions and 23 deletions

View File

@@ -14,6 +14,7 @@ import {
HEARTBEAT_SYSTEM_PROMPT,
HEARTBEAT_NO_TASK_SYSTEM_PROMPT,
HEARTBEAT_PROCEDURE,
HEARTBEAT_NO_TASK_PROCEDURE,
} from "../agent-heartbeat.js";
import { AgentLogger } from "../agent-logger.js";
import * as agentTools from "../agent-tools.js";
@@ -1657,10 +1658,11 @@ describe("HeartbeatMonitor", () => {
// Should NOT include task-specific content
expect(executionPrompt).not.toContain("Assigned task:");
expect(executionPrompt).not.toContain("Task description:");
// Should include Wake Delta + Heartbeat Procedure (paperclip-style per-tick anchoring)
// Should include Wake Delta + no-task heartbeat procedure (tool-aligned per-tick anchoring)
expect(executionPrompt).toContain("## Wake Delta");
expect(executionPrompt).toContain("wake reason:");
expect(executionPrompt).toContain(HEARTBEAT_PROCEDURE);
expect(executionPrompt).toContain("autonomous heartbeat run");
expect(executionPrompt).toContain(HEARTBEAT_NO_TASK_PROCEDURE);
});
it("task-scoped run receives HEARTBEAT_SYSTEM_PROMPT as system prompt", async () => {
@@ -1682,6 +1684,22 @@ describe("HeartbeatMonitor", () => {
expect(systemPrompt).toContain("Task Documents:");
});
it("timer task-scoped execution prompt is framed as autonomous heartbeat work", async () => {
const store = createStoreWithAgentForExec({ taskId: "FN-001" });
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
const promptCalls = mockSession.prompt.mock.calls;
expect(promptCalls.length).toBeGreaterThan(0);
const executionPrompt = promptCalls[promptCalls.length - 1]![0]!;
expect(executionPrompt).toContain("## Wake Delta");
expect(executionPrompt).toContain("wake reason: timer");
expect(executionPrompt).toContain("autonomous heartbeat run");
});
it("identity agent without task gets soul in system prompt", async () => {
const store = createStoreWithAgentForExec({ taskId: undefined, soul: "I am a CEO who prioritizes high-impact work" });
const mockSession = createMockAgentSession();
@@ -1698,6 +1716,33 @@ describe("HeartbeatMonitor", () => {
expect(callArgs.systemPrompt).toContain("I am a CEO who prioritizes high-impact work");
});
it("builds heartbeat system prompt with inline + file instructions plus soul and memory", async () => {
const tmpRoot = mkdtempSync(join(tmpdir(), "fn-hb-instr-"));
try {
writeFileSync(join(tmpRoot, "instructions.md"), "File-backed operating instruction", "utf-8");
const store = createStoreWithAgentForExec({
taskId: undefined,
instructionsText: "Inline operating instruction",
instructionsPath: "instructions.md",
soul: "I am an autonomous agent",
memory: "Remember to prefer concrete actions",
});
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: tmpRoot });
await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
const callArgs = mockedCreateFnAgent.mock.calls[0]![0]!;
expect(callArgs.systemPrompt).toContain("Inline operating instruction");
expect(callArgs.systemPrompt).toContain("File-backed operating instruction");
expect(callArgs.systemPrompt).toContain("## Soul");
expect(callArgs.systemPrompt).toContain("## Agent Memory");
} finally {
rmSync(tmpRoot, { recursive: true, force: true });
}
});
it("agent WITHOUT identity (no soul, instructions, memory) still exits with no_assignment", async () => {
// Agent with empty strings should also exit gracefully
const store = createStoreWithAgentForExec({
@@ -2134,6 +2179,7 @@ describe("HeartbeatMonitor", () => {
// assigned task (paperclip-parity).
expect(executionPrompt).toContain("## Wake Delta");
expect(executionPrompt).toContain("wake reason: message_received");
expect(executionPrompt).toContain("autonomous heartbeat run");
expect(executionPrompt).toContain(HEARTBEAT_PROCEDURE);
});
@@ -2781,6 +2827,14 @@ describe("HeartbeatMonitor", () => {
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).toContain("fn_heartbeat_done");
});
it("no-task heartbeat procedure aligns with ambient tools", () => {
expect(HEARTBEAT_NO_TASK_PROCEDURE).not.toContain("fn_task_log");
expect(HEARTBEAT_NO_TASK_PROCEDURE).not.toContain("fn_task_document_write");
expect(HEARTBEAT_NO_TASK_PROCEDURE).toContain("fn_task_create");
expect(HEARTBEAT_NO_TASK_PROCEDURE).toContain("fn_delegate_task");
expect(HEARTBEAT_NO_TASK_PROCEDURE).toContain("fn_memory_append");
});
it("task-scoped system prompt still references fn_task_log and fn_task_document tools", () => {
expect(HEARTBEAT_SYSTEM_PROMPT).toContain("fn_task_log");
expect(HEARTBEAT_SYSTEM_PROMPT).toContain("fn_task_document_write");

View File

@@ -319,6 +319,37 @@ Critical: a heartbeat without observable progress (a log, a document write, a
status change, a comment, a delegation, or an explicit "no-op with reason") is
a bug. Do not loop on the same plan across heartbeats without recording why.`;
/**
* No-task variant of HEARTBEAT_PROCEDURE. Keep this aligned with the ambient
* tool set (no fn_task_log / fn_task_document_* in no-task runs).
*/
export const HEARTBEAT_NO_TASK_PROCEDURE = `## Heartbeat Procedure (run every tick, in order)
1. **Identity & context** — review the **Identity Snapshot** at the top of
this prompt. Confirm your role, soul, instructions, and memory match what
you expect, and surface any anomalies in your first text output before
doing anything else. (If fn_identity is available in your runtime you may
also call it for full structured detail; the snapshot above is the
authoritative source.)
2. **Inbox** — when fn_read_messages is available, call it. Process any pending
messages first; reply with reply_to_message_id when answering.
3. **Wake delta** — read the Wake Delta block above. The wake reason is the
highest-priority change for this heartbeat. If you were woken by a comment
or a message, acknowledge it before doing anything else.
4. **Ambient review** — since you have no assigned task, review board/project
signals and recent memory context before acting.
5. **Pick the next concrete action** — exactly ONE useful action this heartbeat:
create a focused task, delegate work, send/reply to a message, or append
durable memory.
6. **Persist progress** — use available ambient tools only:
fn_task_create, fn_delegate_task, fn_send_message, fn_memory_append.
7. **Exit** — call fn_heartbeat_done with a one-line summary of what changed
this tick. If you took no action, say so and explain why.
Critical: a heartbeat without observable progress (a created task, delegation,
message reply, memory append, or explicit "no-op with reason") is a bug. Do
not loop on the same plan across heartbeats without recording why.`;
/** Parameter schema for the fn_heartbeat_done tool */
const heartbeatDoneParams = Type.Object({
summary: Type.Optional(Type.String({ description: "Summary of what was accomplished this heartbeat" })),
@@ -438,13 +469,6 @@ export class HeartbeatMonitor {
this.rootDir = options.rootDir;
this.messageStore = options.messageStore;
this.pluginRunner = options.pluginRunner;
this.onRecovered = options.onRecovered;
this.onTerminated = options.onTerminated;
this.onRunStarted = options.onRunStarted;
this.onRunCompleted = options.onRunCompleted;
this.taskStore = options.taskStore;
this.rootDir = options.rootDir;
this.messageStore = options.messageStore;
}
/**
@@ -1365,27 +1389,32 @@ export class HeartbeatMonitor {
// Build skill selection context for heartbeat session (uses waking agent's skills, no role fallback)
const skillContext = buildSessionSkillContextSync(agent, "heartbeat", rootDir);
let systemPrompt = isNoTaskRun
const baseHeartbeatSystemPrompt = isNoTaskRun
? HEARTBEAT_NO_TASK_SYSTEM_PROMPT
: HEARTBEAT_SYSTEM_PROMPT;
const baseHeartbeatSystemPrompt = systemPrompt;
let resolvedInstructionsForIdentity = "";
try {
const agentInstructions = await resolveAgentInstructionsWithRatings(agent, rootDir, this.store);
resolvedInstructionsForIdentity = agentInstructions;
const memoryInstructions = memorySettings?.memoryEnabled === false
? ""
: buildExecutionMemoryInstructions(rootDir, memorySettings);
systemPrompt = buildSystemPromptWithInstructions(
baseHeartbeatSystemPrompt,
[agentInstructions, memoryInstructions].filter((part) => part.trim()).join("\n\n"),
);
resolvedInstructionsForIdentity = await resolveAgentInstructionsWithRatings(agent, rootDir, this.store);
} catch (instructionError) {
systemPrompt = baseHeartbeatSystemPrompt;
const message = instructionError instanceof Error ? instructionError.message : String(instructionError);
heartbeatLog.warn(`Failed to enrich heartbeat system prompt for ${agentId}: ${message}`);
heartbeatLog.warn(`Failed to resolve agent instructions for heartbeat ${agentId}: ${message}`);
}
let memoryInstructions = "";
if (memorySettings?.memoryEnabled !== false) {
try {
memoryInstructions = buildExecutionMemoryInstructions(rootDir, memorySettings);
} catch (memoryInstructionErr) {
const message = memoryInstructionErr instanceof Error ? memoryInstructionErr.message : String(memoryInstructionErr);
heartbeatLog.warn(`Failed to resolve project memory instructions for heartbeat ${agentId}: ${message}`);
}
}
const systemPrompt = buildSystemPromptWithInstructions(
baseHeartbeatSystemPrompt,
[resolvedInstructionsForIdentity, memoryInstructions].filter((part) => part.trim()).join("\n\n"),
);
// Register fn_identity tool before fn_heartbeat_done (which must stay last)
heartbeatTools.push(createIdentityTool({ agent, resolvedInstructions: resolvedInstructionsForIdentity }));
@@ -1468,7 +1497,8 @@ 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 ?? HEARTBEAT_PROCEDURE;
const heartbeatProcedureText = customProcedure
?? (isNoTaskRun ? HEARTBEAT_NO_TASK_PROCEDURE : HEARTBEAT_PROCEDURE);
if (isNoTaskRun) {
// No-task heartbeat: agent has identity but no assigned task
@@ -1507,6 +1537,8 @@ export class HeartbeatMonitor {
`- pending messages: ${pendingMessages.length}`,
"",
"Treat this wake delta as the highest-priority change for this heartbeat.",
"This is an autonomous heartbeat run (manual or automatic): re-anchor on",
"identity, process wake context, then complete ONE concrete action.",
"Run the Heartbeat Procedure (below) before doing anything else — even a",
"timer-only wake should re-check messages, memory, and project state.",
"",
@@ -1608,6 +1640,8 @@ export class HeartbeatMonitor {
`- triggering comments: ${effectiveTriggeringCommentIds?.length ?? 0}`,
"",
"Treat this wake delta as the highest-priority change for this heartbeat.",
"This is an autonomous heartbeat run (manual or automatic): re-anchor on",
"identity, process wake context, then complete ONE concrete action.",
"Before resuming prior task work, run the Heartbeat Procedure (below) and",
"decide what action this delta requires. Your assigned task is one input",
"to the procedure — not the only thing to consider.",