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:
5
.changeset/FN-3884-heartbeat-scope-discipline.md
Normal file
5
.changeset/FN-3884-heartbeat-scope-discipline.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
### 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 agent’s existing procedure file are preserved unless this upgrade is run (the upgrade overwrites the per-agent file).
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -1257,6 +1257,7 @@ describe("executeHeartbeat", () => {
|
||||
expect(executionPrompt).toContain("wake reason: message_received");
|
||||
expect(executionPrompt).toContain("autonomous heartbeat run");
|
||||
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 () => {
|
||||
@@ -1977,11 +1978,26 @@ describe("executeHeartbeat", () => {
|
||||
expect(HEARTBEAT_NO_TASK_PROCEDURE.indexOf("**Inbox**")).toBeLessThan(HEARTBEAT_NO_TASK_PROCEDURE.indexOf("**Wake delta**"));
|
||||
});
|
||||
|
||||
it("both heartbeat procedures include scope-classification guidance", () => {
|
||||
expect(HEARTBEAT_PROCEDURE).toContain("Classify scope before acting");
|
||||
expect(HEARTBEAT_PROCEDURE).toContain("Out-of-scope discovery");
|
||||
expect(HEARTBEAT_NO_TASK_PROCEDURE).toContain("Classify scope before acting");
|
||||
expect(HEARTBEAT_NO_TASK_PROCEDURE).toContain("Implementation-scope discovery");
|
||||
it("heartbeat procedures include scope-discipline guidance", () => {
|
||||
expect(HEARTBEAT_PROCEDURE).toContain("executor-class");
|
||||
expect(HEARTBEAT_PROCEDURE).toContain("blocked");
|
||||
expect(HEARTBEAT_PROCEDURE).toContain("coordination-class");
|
||||
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", () => {
|
||||
|
||||
@@ -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
|
||||
highest-priority change for this heartbeat. If you were woken by a comment
|
||||
or a message, acknowledge it before doing anything else.
|
||||
4. **Assignment review** — if you have an assigned task, re-read its current
|
||||
description, latest comments, and any task documents. Decide whether the
|
||||
prior plan is still valid given the wake delta. Do not assume yesterday's
|
||||
plan is still correct.
|
||||
5. **Classify scope before acting** — label the next action as either:
|
||||
- **In-scope execution:** directly advances the assigned task's current
|
||||
acceptance criteria.
|
||||
- **Out-of-scope discovery:** useful but separate work; capture it as a
|
||||
focused follow-up task instead of expanding the current task silently.
|
||||
6. **Pick the next concrete action** — exactly ONE useful action this heartbeat:
|
||||
4. **Classify the bound task** — if you have an assigned task, classify it as
|
||||
exactly one of:
|
||||
- **executor-class** — implementation work: writing code, tests,
|
||||
documentation prose, or running build/lint/typecheck.
|
||||
- **blocked** — task has blockedBy set, or is waiting on a peer / dependency
|
||||
/ external input.
|
||||
- **coordination-class** — planning, triage, routing, decision-making, or
|
||||
review.
|
||||
Then branch:
|
||||
- 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
|
||||
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.
|
||||
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
|
||||
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
|
||||
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.
|
||||
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:
|
||||
- **Board-scope execution:** work that can be completed now with ambient
|
||||
tools (coordination, delegation, messaging, memory updates).
|
||||
|
||||
Reference in New Issue
Block a user