feat(FN-3884): implement heartbeat scope discipline for agent prompts

Adds heartbeat scope-discipline guardrails to the executor's heartbeat procedure, ensuring agents stay focused on their assigned scope rather than drifting to unrelated work. Includes tests for the updated heartbeat prompts and documentation in agents.md, shipped as a patch.

Fusion-Task-Id: FN-3884
This commit is contained in:
Fusion
2026-05-11 03:02:29 -07:00
committed by gsxdsm
parent 3f85d5a5f4
commit 6f2e8c4fc9
4 changed files with 64 additions and 17 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Update the default heartbeat procedure to enforce bound-task scope discipline by classifying work as `executor-class`, `blocked`, or `coordination-class`, and steering executor/blocked ticks toward coordination actions instead of implementation advancement. Existing agents that already have seeded per-agent heartbeat files keep their current content until operators explicitly run the heartbeat-procedure upgrade endpoint, which re-seeds from the latest built-in default.

View File

@@ -700,6 +700,20 @@ Heartbeat runs are composed from multiple prompt layers so each wake has full id
This structure ensures every run re-anchors on identity, wake reason, and current context before taking action. This structure ensures every run re-anchors on identity, wake reason, and current context before taking action.
### Default Procedure: Bound-Task Scope Discipline
The shipped default `HEARTBEAT_PROCEDURE` (in `packages/engine/src/agent-heartbeat.ts`) now requires bound-task classification on each tick: `executor-class`, `blocked`, or `coordination-class`.
- `executor-class` = implementation work (code/tests/docs prose/build-lint-typecheck)
- `blocked` = blockedBy/dependency/peer/external wait state
- `coordination-class` = planning/triage/routing/decision/review work
When the bound task is `executor-class` or `blocked`, the default procedure directs the run to pivot toward coordination levers (in-progress risk scan, stale in-review queue, idle direct reports, strategic memory themes) rather than trying to advance implementation from heartbeat. When the task is `coordination-class`, the heartbeat can engage directly with the bound task.
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 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).
### Manual / On-Demand Runs Are Autonomous Heartbeats ### 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. `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.

View File

@@ -1257,6 +1257,7 @@ describe("executeHeartbeat", () => {
expect(executionPrompt).toContain("wake reason: message_received"); expect(executionPrompt).toContain("wake reason: message_received");
expect(executionPrompt).toContain("autonomous heartbeat run"); expect(executionPrompt).toContain("autonomous heartbeat run");
expect(executionPrompt).toContain(HEARTBEAT_PROCEDURE); expect(executionPrompt).toContain(HEARTBEAT_PROCEDURE);
expect(executionPrompt).toContain("do not re-read PROMPT.md to advance it");
}); });
it("substitutes per-agent heartbeatProcedurePath content for the default procedure", async () => { it("substitutes per-agent heartbeatProcedurePath content for the default procedure", async () => {
@@ -1977,11 +1978,26 @@ describe("executeHeartbeat", () => {
expect(HEARTBEAT_NO_TASK_PROCEDURE.indexOf("**Inbox**")).toBeLessThan(HEARTBEAT_NO_TASK_PROCEDURE.indexOf("**Wake delta**")); expect(HEARTBEAT_NO_TASK_PROCEDURE.indexOf("**Inbox**")).toBeLessThan(HEARTBEAT_NO_TASK_PROCEDURE.indexOf("**Wake delta**"));
}); });
it("both heartbeat procedures include scope-classification guidance", () => { it("heartbeat procedures include scope-discipline guidance", () => {
expect(HEARTBEAT_PROCEDURE).toContain("Classify scope before acting"); expect(HEARTBEAT_PROCEDURE).toContain("executor-class");
expect(HEARTBEAT_PROCEDURE).toContain("Out-of-scope discovery"); expect(HEARTBEAT_PROCEDURE).toContain("blocked");
expect(HEARTBEAT_NO_TASK_PROCEDURE).toContain("Classify scope before acting"); expect(HEARTBEAT_PROCEDURE).toContain("coordination-class");
expect(HEARTBEAT_NO_TASK_PROCEDURE).toContain("Implementation-scope discovery"); expect(HEARTBEAT_PROCEDURE).toContain("do not re-read PROMPT.md to advance it");
expect(HEARTBEAT_PROCEDURE).toContain("avoid re-planning it");
const inboxIndex = HEARTBEAT_PROCEDURE.indexOf("**Inbox**");
const wakeDeltaIndex = HEARTBEAT_PROCEDURE.indexOf("**Wake delta**");
const classifyIndex = HEARTBEAT_PROCEDURE.indexOf("**Classify the bound task**");
const selfCheckIndex = HEARTBEAT_PROCEDURE.indexOf("**Per-tick self-check**");
const exitIndex = HEARTBEAT_PROCEDURE.indexOf("fn_heartbeat_done");
expect(classifyIndex).toBeGreaterThan(inboxIndex);
expect(classifyIndex).toBeGreaterThan(wakeDeltaIndex);
expect(selfCheckIndex).toBeGreaterThan(classifyIndex);
expect(selfCheckIndex).toBeLessThan(exitIndex);
expect(HEARTBEAT_NO_TASK_PROCEDURE).toContain("coordination-class");
expect(HEARTBEAT_NO_TASK_PROCEDURE).not.toContain("executor-class");
expect(HEARTBEAT_NO_TASK_PROCEDURE).not.toContain("do not re-read PROMPT.md to advance it");
}); });
it("no-task system prompt processing messages section does not reference fn_task_log", () => { it("no-task system prompt processing messages section does not reference fn_task_log", () => {

View File

@@ -408,20 +408,31 @@ export const HEARTBEAT_PROCEDURE = `## Heartbeat Procedure (run every tick, in o
3. **Wake delta** — read the Wake Delta block above. The wake reason is the 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 highest-priority change for this heartbeat. If you were woken by a comment
or a message, acknowledge it before doing anything else. or a message, acknowledge it before doing anything else.
4. **Assignment review** — if you have an assigned task, re-read its current 4. **Classify the bound task** — if you have an assigned task, classify it as
description, latest comments, and any task documents. Decide whether the exactly one of:
prior plan is still valid given the wake delta. Do not assume yesterday's - **executor-class** — implementation work: writing code, tests,
plan is still correct. documentation prose, or running build/lint/typecheck.
5. **Classify scope before acting** — label the next action as either: - **blocked** — task has blockedBy set, or is waiting on a peer / dependency
- **In-scope execution:** directly advances the assigned task's current / external input.
acceptance criteria. - **coordination-class** — planning, triage, routing, decision-making, or
- **Out-of-scope discovery:** useful but separate work; capture it as a review.
focused follow-up task instead of expanding the current task silently. Then branch:
6. **Pick the next concrete action** — exactly ONE useful action this heartbeat: - If the bound task is **executor-class** or **blocked**, skim it once for
blocker risk, do not re-read PROMPT.md to advance it, and pivot this
heartbeat to broader board signals (in-progress risk scan, stale in-review
queue, idle direct reports, and strategic themes in memory). Inbox is
already handled in step 2.
- If the bound task is **coordination-class**, engage directly with the
bound task.
5. **Pick the next concrete action** — exactly ONE useful action this heartbeat:
advance the task, create a follow-up, log findings, delegate, or update advance the task, create a follow-up, log findings, delegate, or update
memory. Don't stop at planning unless the task is a planning task. memory. Don't stop at planning unless the task is a planning task.
7. **Persist progress** — fn_task_log for observations, fn_task_document_write 6. **Persist progress** — fn_task_log for observations, fn_task_document_write
for durable findings, status updates only when the work warrants it. for durable findings, status updates only when the work warrants it.
7. **Per-tick self-check** — before exiting, verify all three:
- Was the inbox processed?
- Is the chosen action on a coordination-shaped lever?
- If the bound task was executor-class, did I avoid re-planning it?
8. **Exit** — call fn_heartbeat_done with a one-line summary of what changed 8. **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. this tick. If you took no action, say so and explain why.
@@ -447,7 +458,8 @@ export const HEARTBEAT_NO_TASK_PROCEDURE = `## Heartbeat Procedure (run every ti
highest-priority change for this heartbeat. If you were woken by a comment highest-priority change for this heartbeat. If you were woken by a comment
or a message, acknowledge it before doing anything else. or a message, acknowledge it before doing anything else.
4. **Ambient review** — since you have no assigned task, review board/project 4. **Ambient review** — since you have no assigned task, review board/project
signals and recent memory context before acting. signals and recent memory context before acting. No-task heartbeat runs are
inherently coordination-class because no bound task exists to classify.
5. **Classify scope before acting** — label the next action as either: 5. **Classify scope before acting** — label the next action as either:
- **Board-scope execution:** work that can be completed now with ambient - **Board-scope execution:** work that can be completed now with ambient
tools (coordination, delegation, messaging, memory updates). tools (coordination, delegation, messaging, memory updates).