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 787d0734b4
commit fd36fbdf9d
4 changed files with 149 additions and 23 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix heartbeat run prompt composition so manual and automatic runs consistently include agent identity/instructions context and autonomous heartbeat framing for both task-scoped and no-task execution.

View File

@@ -408,6 +408,39 @@ fn message delete MSG-123
fn agent mailbox AGENT-001
```
## Heartbeat Prompt Composition and Autonomous Run Behavior
Heartbeat runs are composed from multiple prompt layers so each wake has full identity and operating context:
1. **System prompt**
- Task-scoped runs use the task heartbeat system prompt.
- No-task runs use the ambient/no-task heartbeat system prompt (tool-aligned: no task-scoped tools).
2. **Agent identity and instructions bundle**
- Inline instructions (`instructionsText`)
- File-backed instructions (`instructionsPath`)
- Soul/personality (`soul`)
- Agent memory (`memory`)
- Optional project memory guidance (when memory is enabled)
3. **Execution prompt framing**
- `Identity Snapshot` block (agent ID/role + loaded soul/instructions/memory preview)
- `Wake Delta` block (source, trigger detail, wake reason, assignment/comments/messages)
- Heartbeat procedure block (task-scoped or no-task variant, plus optional per-agent procedure override file)
This structure ensures every run re-anchors on identity, wake reason, and current context before taking action.
### Manual / On-Demand Runs Are Autonomous Heartbeats
`POST /api/agents/:id/runs` with `source: "on_demand"` executes the same autonomous heartbeat flow as timer/assignment triggers. It is **not** a mailbox-only poll.
Expected behavior for both manual and automatic triggers:
- Re-check identity/instructions context for this tick
- Process wake delta first (including message/comment wakes)
- Re-evaluate assignment state
- Take exactly one concrete next action
- Finish with `fn_heartbeat_done`
Messages remain an important input signal, but they do not replace the heartbeat procedure.
## Heartbeat Run Mailbox Checking
When messaging tools are enabled for an agent, heartbeat runs check for unread mailbox messages during execution regardless of the trigger type. This ensures agents can see and respond to incoming messages without needing an explicit wake-on-message trigger.

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.",