FN-7963: add plannerHeartbeatPatrolEnabled to gate idle heartbeat task creation

Add a workflow setting that disables idle/no-task heartbeat proactive task creation without turning off planner overseer stuck-task recovery.

- Declare plannerHeartbeatPatrolEnabled (default true) in BUILTIN_OVERSIGHT_SETTINGS
- Resolve the flag via resolveEffectivePlannerHeartbeatPatrolEnabled and wire it into agent-heartbeat/triage prompts
- Render patrol-off instruction when disabled; keep FN-7962 outage backoff lines when patrol stays enabled
- Cover setting defaults, prompt builders, and heartbeat executor paths with tests
- Document the setting in settings-reference and add a changeset

Files changed:
 .changeset/fn-7963-planner-heartbeat-patrol.md     |   7 ++
 docs/settings-reference.md                         |  11 +-
 packages/core/src/__tests__/agent-prompts.test.ts  |  29 +++++
 .../builtin-workflow-settings-triage.test.ts       |  21 ++++
 .../plannerHeartbeatPatrolEnabled-default.test.ts  |  64 ++++++++++
 packages/core/src/agent-prompts.ts                 |  55 +++++++--
 packages/core/src/builtin-workflow-settings.ts     |  14 +++
 packages/core/src/index.gate.ts                    |   5 +
 packages/core/src/index.ts                         |   5 +
 packages/core/src/workflow-settings-resolver.ts    |  15 ++-
 .../src/__tests__/heartbeat-executor.test.ts       |  59 ++++++++-
 packages/engine/src/agent-heartbeat.ts             | 135 +++++++++++++++++++--
 packages/engine/src/triage.ts                      |   8 +-
 13 files changed, 402 insertions(+), 26 deletions(-)

Fusion-Task-Id: FN-7963

Fusion-Task-Lineage: c5e7a382-52c1-4cc1-8b21-aba7dc7d2b97

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-15 12:27:15 -07:00
parent b2977d1a7a
commit de25e32eac
13 changed files with 402 additions and 26 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Add a workflow setting to disable idle heartbeat task patrol.
category: feature
dev: Adds plannerHeartbeatPatrolEnabled for no-task heartbeat and triage prompt gating.

View File

@@ -295,8 +295,9 @@ Actions. It has two tabs:
automatic revisions, enter a non-negative integer to cap attempts, or enter `0` automatic revisions, enter a non-negative integer to cap attempts, or enter `0`
to disable automatic revision for that path. `plannerOversightLevel` is the to disable automatic revision for that path. `plannerOversightLevel` is the
workflow-native planner oversight mode and accepts `off`, `observe`, `steer`, workflow-native planner oversight mode and accepts `off`, `observe`, `steer`,
or `autonomous` (default). Edits batch and commit through a single **Save** in or `autonomous` (default). `plannerHeartbeatPatrolEnabled` controls idle/no-task
the Values tab. heartbeat patrol task creation separately and defaults to `true`. Edits batch
and commit through a single **Save** in the Values tab.
**How values resolve.** The engine resolves *effective settings* per task as **How values resolve.** The engine resolves *effective settings* per task as
`stored value ?? declaration default`. The task-detail Workflow, Chat, and Agent `stored value ?? declaration default`. The task-detail Workflow, Chat, and Agent
@@ -335,7 +336,7 @@ These groups moved out of project settings and into workflow settings (built-in
|---|---| |---|---|
| **Step execution** | `workflowStepTimeoutMs`, `runStepsInNewSessions`, `maxParallelSteps`, `workflowStepScopeEnforcement`, `strictScopeEnforcement`, `verificationFixRetries`, `maxPostReviewFixes`, `buildRetryCount` | | **Step execution** | `workflowStepTimeoutMs`, `runStepsInNewSessions`, `maxParallelSteps`, `workflowStepScopeEnforcement`, `strictScopeEnforcement`, `verificationFixRetries`, `maxPostReviewFixes`, `buildRetryCount` |
| **Review / approval** | Workflow values: `requirePrApproval`, `requirePlanApproval`, `reviewHandoffPolicy`, `maxReviewerContextRetries`, `maxReviewerFallbackRetries`, `planReviewMaxRevisions`, `codeReviewMaxRevisions`; project override: `planApprovalMode` | | **Review / approval** | Workflow values: `requirePrApproval`, `requirePlanApproval`, `reviewHandoffPolicy`, `maxReviewerContextRetries`, `maxReviewerFallbackRetries`, `planReviewMaxRevisions`, `codeReviewMaxRevisions`; project override: `planApprovalMode` |
| **Planner oversight** | `plannerOversightLevel` (workflow-native; values: `off`, `observe`, `steer`, `autonomous`); `plannerOversightNotificationLevel` (workflow-native; values: `silent`, `errors`, `important`, `all`); `plannerOverseerExecutorStuckAfterMs` (workflow-native; number, default `7200000` = 2h); `plannerOverseerAdvisorEnabled` (boolean, **default false**); `plannerOverseerAdvisorProvider` / `plannerOverseerAdvisorModelId` (session-advisor model; both required when enabled) | | **Planner oversight** | `plannerOversightLevel` (workflow-native; values: `off`, `observe`, `steer`, `autonomous`); `plannerOversightNotificationLevel` (workflow-native; values: `silent`, `errors`, `important`, `all`); `plannerOverseerExecutorStuckAfterMs` (workflow-native; number, default `7200000` = 2h); `plannerOverseerAdvisorEnabled` (boolean, **default false**); `plannerOverseerAdvisorProvider` / `plannerOverseerAdvisorModelId` (session-advisor model; both required when enabled); `plannerHeartbeatPatrolEnabled` (workflow-native; boolean, default `true`, gates idle/no-task heartbeat patrol task creation) |
| **Per-phase model lanes** | `executionProvider`/`executionModelId` + `executionThinkingLevel`, `planningProvider`/`planningModelId` + `planningThinkingLevel` (+ fallbacks), `validatorProvider`/`validatorModelId` + `validatorThinkingLevel` (+ fallbacks). Thinking values accept `off`, `minimal`, `low`, `medium`, `high`, or `xhigh`; unset inherits. | | **Per-phase model lanes** | `executionProvider`/`executionModelId` + `executionThinkingLevel`, `planningProvider`/`planningModelId` + `planningThinkingLevel` (+ fallbacks), `validatorProvider`/`validatorModelId` + `validatorThinkingLevel` (+ fallbacks). Thinking values accept `off`, `minimal`, `low`, `medium`, `high`, or `xhigh`; unset inherits. |
### Workflow-native triage policy settings ### Workflow-native triage policy settings
@@ -346,6 +347,9 @@ Triage workflow defaults are policy inputs, not permission to reroute tasks auto
FNXC:TriagePolicy 2026-07-04-00:00: FNXC:TriagePolicy 2026-07-04-00:00:
`triageProactiveSubtaskSplittingEnabled` is workflow/project-scoped so operators can disable automatic oversized-task splitting without disabling explicit per-task `breakIntoSubtasks: true` requests. `triageProactiveSubtaskSplittingEnabled` is workflow/project-scoped so operators can disable automatic oversized-task splitting without disabling explicit per-task `breakIntoSubtasks: true` requests.
FNXC:HeartbeatPatrol 2026-07-15-03:05:
`plannerHeartbeatPatrolEnabled` is documented beside planner oversight because operators need a separate workflow-native switch for idle/no-task task-creation patrol. It must not be described as disabling stuck-task observation, steering, or recovery for tasks already in flight.
--> -->
The built-in workflows also declare triage/spec policy settings that were **not** moved from project settings. They are workflow-native declarations: they never lived in `DEFAULT_PROJECT_SETTINGS`, are not `MOVED_SETTINGS_KEYS`, and resolve only through the workflow effective-settings path. The built-in workflows also declare triage/spec policy settings that were **not** moved from project settings. They are workflow-native declarations: they never lived in `DEFAULT_PROJECT_SETTINGS`, are not `MOVED_SETTINGS_KEYS`, and resolve only through the workflow effective-settings path.
@@ -375,6 +379,7 @@ The built-in workflows also declare triage/spec policy settings that were **not*
| `plannerOverseerAdvisorProvider` | `""` | Session-advisor model provider (OMP advisor parity). Used only when `plannerOverseerAdvisorEnabled` is true. Must be set together with `plannerOverseerAdvisorModelId`. | | `plannerOverseerAdvisorProvider` | `""` | Session-advisor model provider (OMP advisor parity). Used only when `plannerOverseerAdvisorEnabled` is true. Must be set together with `plannerOverseerAdvisorModelId`. |
| `plannerOverseerAdvisorModelId` | `""` | Session-advisor model id. Used only when `plannerOverseerAdvisorEnabled` is true. When enabled and both model fields are set, the advisor reviews executor agent-log deltas and may inject `[session-advisor]` steering comments at `steer`/`autonomous` (observe = log only). Discover project review priorities via `OVERSEER.md` / `WATCHDOG.md`. See `docs/architecture.md` → "Planner overseer session advisor". | | `plannerOverseerAdvisorModelId` | `""` | Session-advisor model id. Used only when `plannerOverseerAdvisorEnabled` is true. When enabled and both model fields are set, the advisor reviews executor agent-log deltas and may inject `[session-advisor]` steering comments at `steer`/`autonomous` (observe = log only). Discover project review priorities via `OVERSEER.md` / `WATCHDOG.md`. See `docs/architecture.md` → "Planner overseer session advisor". |
| `plannerOverseerExecutorStuckAfterMs` | `7200000` (2h) | Workflow-native executor-stage stall threshold (FN-7743). Milliseconds of executor-stage inactivity — no execution activity since the task's last column move/update (`columnMovedAt ?? updatedAt`) — before a non-paused `in-progress` task is reported `signal: "stuck"` instead of `"progressing"`, feeding the existing `decidePlannerRecovery` → bounded `inject_guidance` recovery path at the `autonomous` oversight level (no effect at `off`/`observe`/`steer`). Fixes the class of bug where a genuinely hung/idle executor (dead session, silent agent) was indistinguishable from a healthy one and was never nudged, retried, or escalated. A missing/malformed activity timestamp degrades to `"progressing"` (fail-safe — never fabricates a stall), and a user-paused/approval-blocked/`autoMerge:false` task is still fully withheld from any autonomous action regardless of this threshold. Resolves through the generic `resolveEffectiveSettings` default path alongside `plannerOversightLevel`. See `docs/architecture.md` → "Executor-stage stall detection (FN-7743)". | | `plannerOverseerExecutorStuckAfterMs` | `7200000` (2h) | Workflow-native executor-stage stall threshold (FN-7743). Milliseconds of executor-stage inactivity — no execution activity since the task's last column move/update (`columnMovedAt ?? updatedAt`) — before a non-paused `in-progress` task is reported `signal: "stuck"` instead of `"progressing"`, feeding the existing `decidePlannerRecovery` → bounded `inject_guidance` recovery path at the `autonomous` oversight level (no effect at `off`/`observe`/`steer`). Fixes the class of bug where a genuinely hung/idle executor (dead session, silent agent) was indistinguishable from a healthy one and was never nudged, retried, or escalated. A missing/malformed activity timestamp degrades to `"progressing"` (fail-safe — never fabricates a stall), and a user-paused/approval-blocked/`autoMerge:false` task is still fully withheld from any autonomous action regardless of this threshold. Resolves through the generic `resolveEffectiveSettings` default path alongside `plannerOversightLevel`. See `docs/architecture.md` → "Executor-stage stall detection (FN-7743)". |
| `plannerHeartbeatPatrolEnabled` | `true` | Workflow-native idle-heartbeat patrol switch (FN-7963). `true` preserves the existing no-task heartbeat/triage guidance that lets idle agents scan for gaps and create focused follow-up tasks. Set to `false` to remove proactive patrol task-creation guidance from idle/no-task heartbeat prompts; agents should then handle assigned work, direct messages, explicit operator requests, and safe read-only/logging coordination instead of opening new patrol tasks. This is separate from `plannerOversightLevel`: disabling heartbeat patrol does **not** disable stuck-task observation, steering, retry, or targeted-fix recovery for tasks already in flight. No-task heartbeats resolve this value from the project default workflow, falling back to `builtin:coding` when no default workflow is set. |
When `triageProactiveSubtaskSplittingEnabled` is `true` (the default), triage may proactively replace a large task with 2-5 child tasks when the size, step-count, package breadth, file-scope, or remediation-batch signals justify the coordination overhead. When it is `false`, those automatic oversized-task signals are advisory only for writing a realistic single-task spec; triage must not split solely because the task is large. The per-task `breakIntoSubtasks: true` flag is separate and remains mandatory: if a user explicitly asks for subtask breakdown, triage still evaluates and creates child tasks when the work is meaningfully decomposable. When `triageProactiveSubtaskSplittingEnabled` is `true` (the default), triage may proactively replace a large task with 2-5 child tasks when the size, step-count, package breadth, file-scope, or remediation-batch signals justify the coordination overhead. When it is `false`, those automatic oversized-task signals are advisory only for writing a realistic single-task spec; triage must not split solely because the task is large. The per-task `breakIntoSubtasks: true` flag is separate and remains mandatory: if a user explicitly asks for subtask breakdown, triage still evaluates and creates child tasks when the work is meaningfully decomposable.

View File

@@ -8,6 +8,7 @@ import {
getAvailableTemplates, getAvailableTemplates,
getTemplatesForRole, getTemplatesForRole,
FUSION_RUNTIME_SELF_AWARENESS, FUSION_RUNTIME_SELF_AWARENESS,
TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION,
} from "../agent-prompts.js"; } from "../agent-prompts.js";
import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js"; import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js";
import { BUILTIN_SEAM_PROMPTS, builtinSeamPrompt } from "../builtin-workflow-prompts.js"; import { BUILTIN_SEAM_PROMPTS, builtinSeamPrompt } from "../builtin-workflow-prompts.js";
@@ -33,6 +34,34 @@ describe("resolveAgentPrompt", () => {
expect(result).toContain("task specification agent"); expect(result).toContain("task specification agent");
}); });
it("renders triage heartbeat patrol guidance by default and when explicitly enabled", () => {
const defaultPrompt = resolveAgentPrompt("triage");
const enabledPrompt = resolveAgentPrompt("triage", undefined, { plannerHeartbeatPatrolEnabled: true });
for (const prompt of [defaultPrompt, enabledPrompt]) {
expect(prompt).toContain("Patrol for vague requests");
expect(prompt).toContain("review follow-ups that should become new tasks");
expect(prompt).not.toContain(TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION);
}
});
it("renders no-patrol triage heartbeat instructions when disabled", () => {
const result = resolveAgentPrompt("triage", undefined, { plannerHeartbeatPatrolEnabled: false });
expect(result).toContain(TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION);
expect(result).not.toContain("Patrol for vague requests");
expect(result).not.toContain("review follow-ups that should become new tasks");
});
it("renders no-patrol concise triage heartbeat instructions when disabled", () => {
const result = resolveAgentPrompt("triage", {
roleAssignments: { triage: "concise-triage" },
}, { plannerHeartbeatPatrolEnabled: false });
expect(result).toContain(TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION);
expect(result).not.toContain("turn it into short, actionable task specs or follow-up tickets");
});
it("returns the correct built-in prompt for reviewer when no config provided", () => { it("returns the correct built-in prompt for reviewer when no config provided", () => {
const result = resolveAgentPrompt("reviewer"); const result = resolveAgentPrompt("reviewer");
expect(result).toBeTruthy(); expect(result).toBeTruthy();

View File

@@ -93,6 +93,7 @@ describe("workflow-native built-in workflow settings", () => {
"plannerOverseerAdvisorEnabled", "plannerOverseerAdvisorEnabled",
"plannerOverseerAdvisorProvider", "plannerOverseerAdvisorProvider",
"plannerOverseerAdvisorModelId", "plannerOverseerAdvisorModelId",
"plannerHeartbeatPatrolEnabled",
]); ]);
// FNXC:PlannerOversight 2026-07-14-12:00: LLM session advisor must default OFF. // FNXC:PlannerOversight 2026-07-14-12:00: LLM session advisor must default OFF.
expect(BUILTIN_OVERSIGHT_SETTINGS.find((s) => s.id === "plannerOverseerAdvisorEnabled")).toMatchObject({ expect(BUILTIN_OVERSIGHT_SETTINGS.find((s) => s.id === "plannerOverseerAdvisorEnabled")).toMatchObject({
@@ -174,6 +175,26 @@ describe("workflow-native built-in workflow settings", () => {
movedKeyIds.has("plannerOverseerExecutorStuckAfterMs"), movedKeyIds.has("plannerOverseerExecutorStuckAfterMs"),
"plannerOverseerExecutorStuckAfterMs should not be in MOVED_SETTINGS_KEYS", "plannerOverseerExecutorStuckAfterMs should not be in MOVED_SETTINGS_KEYS",
).toBe(false); ).toBe(false);
const heartbeatPatrol = BUILTIN_OVERSIGHT_SETTINGS[6];
expect(heartbeatPatrol).toMatchObject({
id: "plannerHeartbeatPatrolEnabled",
type: "boolean",
default: true,
});
expect(heartbeatPatrol.description).toMatch(/idle\/no-task heartbeat proactive patrol/i);
expect(
fullIds.has("plannerHeartbeatPatrolEnabled"),
"plannerHeartbeatPatrolEnabled should be in the full built-in catalog",
).toBe(true);
expect(
movedIds.has("plannerHeartbeatPatrolEnabled"),
"plannerHeartbeatPatrolEnabled should not be in the moved-key catalog",
).toBe(false);
expect(
movedKeyIds.has("plannerHeartbeatPatrolEnabled"),
"plannerHeartbeatPatrolEnabled should not be in MOVED_SETTINGS_KEYS",
).toBe(false);
}); });
it("renders placeholders from resolved settings and rejects dangling tokens", () => { it("renders placeholders from resolved settings and rejects dangling tokens", () => {

View File

@@ -0,0 +1,64 @@
/*
* FNXC:HeartbeatPatrol 2026-07-14-23:38:
* Idle no-task heartbeat patrol defaults on for compatibility, but an explicit workflow false must survive effective-settings resolution so operators can disable proactive task creation without touching planner oversight recovery.
*/
import { describe, expect, it, vi } from "vitest";
import { BUILTIN_OVERSIGHT_SETTINGS, BUILTIN_WORKFLOW_SETTINGS } from "../builtin-workflow-settings.js";
import {
resolveEffectivePlannerHeartbeatPatrolEnabled,
resolveEffectiveSettingsById,
type WorkflowSettingsResolverStore,
} from "../workflow-settings-resolver.js";
const PROJECT = "proj-1";
function makeStore(values?: Record<string, unknown>): WorkflowSettingsResolverStore {
return {
getTaskWorkflowSelection: vi.fn(() => undefined),
getWorkflowDefinition: vi.fn(async () => undefined),
getWorkflowSettingValues: vi.fn(() => values ?? {}),
getWorkflowSettingsProjectId: vi.fn(() => PROJECT),
};
}
describe("plannerHeartbeatPatrolEnabled default (FN-7963)", () => {
it("declares the workflow-native boolean default in the full catalog", () => {
const decl = BUILTIN_OVERSIGHT_SETTINGS.find((setting) => setting.id === "plannerHeartbeatPatrolEnabled");
expect(decl).toMatchObject({
type: "boolean",
default: true,
});
expect(BUILTIN_WORKFLOW_SETTINGS.some((setting) => setting.id === "plannerHeartbeatPatrolEnabled")).toBe(true);
});
it("resolves unset built-in workflow patrol to true", async () => {
const eff = await resolveEffectiveSettingsById(makeStore({}), "builtin:coding", PROJECT);
expect(eff.plannerHeartbeatPatrolEnabled).toBe(true);
expect(resolveEffectivePlannerHeartbeatPatrolEnabled(eff)).toBe(true);
});
it("honors explicit stored false", async () => {
const eff = await resolveEffectiveSettingsById(
makeStore({ plannerHeartbeatPatrolEnabled: false }),
"builtin:coding",
PROJECT,
);
expect(eff.plannerHeartbeatPatrolEnabled).toBe(false);
expect(resolveEffectivePlannerHeartbeatPatrolEnabled(eff)).toBe(false);
});
it("honors explicit stored true", async () => {
const eff = await resolveEffectiveSettingsById(
makeStore({ plannerHeartbeatPatrolEnabled: true }),
"builtin:coding",
PROJECT,
);
expect(eff.plannerHeartbeatPatrolEnabled).toBe(true);
expect(resolveEffectivePlannerHeartbeatPatrolEnabled(eff)).toBe(true);
});
});

View File

@@ -1224,19 +1224,34 @@ Treat each heartbeat as a short autonomous execution cycle.
- If no task is assigned: execute your standing instructions. Review unread messages, scan for blocked or failing engineering work, create narrowly scoped follow-up tasks, and capture durable implementation notes other agents will need later. - If no task is assigned: execute your standing instructions. Review unread messages, scan for blocked or failing engineering work, create narrowly scoped follow-up tasks, and capture durable implementation notes other agents will need later.
- Do not idle simply because no task is linked. Use heartbeat time to reduce engineering risk, unblock work, and keep execution moving in small, concrete increments.`; - Do not idle simply because no task is linked. Use heartbeat time to reduce engineering risk, unblock work, and keep execution moving in small, concrete increments.`;
export const TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION = "If no task is assigned: do not create new tasks during idle/no-task heartbeats. Only handle assigned work, direct messages, explicit operator requests, and safe read-only/logging coordination.";
/* /*
FNXC:HeartbeatPatrol 2026-07-14-00:00: FNXC:HeartbeatPatrol 2026-07-14-00:00:
Idle planning patrol should keep queues actionable, but must not amplify provider outages by creating more work while recent model-availability, fallback-exhaustion, rate-limit, or model-unavailable failures are visible. Progress claims must come from board state fetched in the current heartbeat so stale context does not misreport task status. Idle planning patrol should keep queues actionable, but must not amplify provider outages by creating more work while recent model-availability, fallback-exhaustion, rate-limit, or model-unavailable failures are visible. Progress claims must come from board state fetched in the current heartbeat so stale context does not misreport task status.
FNXC:HeartbeatPatrol 2026-07-14-23:41:
Triage heartbeat patrol is prompt guidance, not planner-overseer recovery. Render the no-task line from the workflow setting so disabling patrol stops idle task-creation nudges while leaving assigned-task triage behavior unchanged.
*/ */
const TRIAGE_HEARTBEAT_GUIDANCE = `## Heartbeat Run Behavior export function buildTriageHeartbeatGuidance(options: { plannerHeartbeatPatrolEnabled?: boolean } = {}): string {
const noTaskLine = options.plannerHeartbeatPatrolEnabled === false
? TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION
: "If no task is assigned: execute your planning instructions. Patrol for vague requests, blocked tasks that need better specification, review follow-ups that should become new tasks, and dependency gaps that are slowing executors down.";
const patrolSafetyLines = options.plannerHeartbeatPatrolEnabled === false
? ""
: `
- Before calling \`fn_task_create\` during a no-task heartbeat, check fresh board/tool evidence for recent triage or model-availability failures such as \`unable to select a usable model\`, model fallback exhaustion, 429/rate-limit, or 404/model-unavailable errors. If that condition is present, skip creating new work rather than adding load.
- Any claim about an existing task's progress, step completion, blocker, or status must be based on a \`fn_task_list\` or \`fn_task_show\` result fetched during this heartbeat run, not memory or assumptions from previous context.`;
return `## Heartbeat Run Behavior
Use heartbeat runs to keep the planning pipeline healthy. Use heartbeat runs to keep the planning pipeline healthy.
- If a task is assigned: turn the rough request into a complete, execution-ready PROMPT.md with clear scope, steps, dependencies, and verification criteria. - If a task is assigned: turn the rough request into a complete, execution-ready PROMPT.md with clear scope, steps, dependencies, and verification criteria.
- If no task is assigned: execute your planning instructions. Patrol for vague requests, blocked tasks that need better specification, review follow-ups that should become new tasks, and dependency gaps that are slowing executors down. - ${noTaskLine}${patrolSafetyLines}
- Before calling \`fn_task_create\` during a no-task heartbeat, check fresh board/tool evidence for recent triage or model-availability failures such as \`unable to select a usable model\`, model fallback exhaustion, 429/rate-limit, or 404/model-unavailable errors. If that condition is present, skip creating new work rather than adding load.
- Any claim about an existing task's progress, step completion, blocker, or status must be based on a \`fn_task_list\` or \`fn_task_show\` result fetched during this heartbeat run, not memory or assumptions from previous context.
- Favor ambiguity reduction over busywork. Every heartbeat should leave the queue more actionable than you found it.`; - Favor ambiguity reduction over busywork. Every heartbeat should leave the queue more actionable than you found it.`;
}
const TRIAGE_HEARTBEAT_GUIDANCE = buildTriageHeartbeatGuidance();
const REVIEWER_HEARTBEAT_GUIDANCE = `## Heartbeat Run Behavior const REVIEWER_HEARTBEAT_GUIDANCE = `## Heartbeat Run Behavior
@@ -1270,15 +1285,25 @@ Use heartbeat runs to enforce a high review bar.
- If no task is assigned: execute your review instructions. Look for merges that feel under-reviewed, risky diffs that deserve another pass, and follow-up work needed before code should land. - If no task is assigned: execute your review instructions. Look for merges that feel under-reviewed, risky diffs that deserve another pass, and follow-up work needed before code should land.
- Bias toward precise findings and explicit risk articulation. A quiet heartbeat should mean the code is genuinely clean, not that you stopped looking.`; - Bias toward precise findings and explicit risk articulation. A quiet heartbeat should mean the code is genuinely clean, not that you stopped looking.`;
const CONCISE_TRIAGE_HEARTBEAT_GUIDANCE = `## Heartbeat Run Behavior export function buildConciseTriageHeartbeatGuidance(options: { plannerHeartbeatPatrolEnabled?: boolean } = {}): string {
const noTaskLine = options.plannerHeartbeatPatrolEnabled === false
? TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION
: "If no task is assigned: execute your planning instructions, scan for underspecified or blocked work, and turn it into short, actionable task specs or follow-up tickets.";
const patrolSafetyLines = options.plannerHeartbeatPatrolEnabled === false
? ""
: `
- Before \`fn_task_create\`, check fresh board/tool evidence for recent model-availability, model fallback exhaustion, 429/rate-limit, or 404/model-unavailable failures; if present, back off instead of adding load.
- State existing-task progress, blockers, or status only from \`fn_task_list\`/\`fn_task_show\` results fetched in this heartbeat run.`;
return `## Heartbeat Run Behavior
Keep heartbeat output lean and useful. Keep heartbeat output lean and useful.
- If a task is assigned: produce the minimum complete PROMPT.md needed for an executor to act safely. - If a task is assigned: produce the minimum complete PROMPT.md needed for an executor to act safely.
- If no task is assigned: execute your planning instructions, scan for underspecified or blocked work, and turn it into short, actionable task specs or follow-up tickets. - ${noTaskLine}${patrolSafetyLines}
- Before \`fn_task_create\`, check fresh board/tool evidence for recent model-availability, model fallback exhaustion, 429/rate-limit, or 404/model-unavailable failures; if present, back off instead of adding load.
- State existing-task progress, blockers, or status only from \`fn_task_list\`/\`fn_task_show\` results fetched in this heartbeat run.
- Prefer crisp decisions, clear file scope, and concrete verification steps over narrative detail.`; - Prefer crisp decisions, clear file scope, and concrete verification steps over narrative detail.`;
}
const CONCISE_TRIAGE_HEARTBEAT_GUIDANCE = buildConciseTriageHeartbeatGuidance();
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Built-in templates array // Built-in templates array
@@ -1368,9 +1393,14 @@ export const BUILTIN_AGENT_PROMPTS: readonly AgentPromptTemplate[] = [
* @throws {Error} If the assigned template ID does not exist in either * @throws {Error} If the assigned template ID does not exist in either
* custom or built-in templates. * custom or built-in templates.
*/ */
export interface ResolveAgentPromptOptions {
plannerHeartbeatPatrolEnabled?: boolean;
}
export function resolveAgentPrompt( export function resolveAgentPrompt(
role: AgentCapability, role: AgentCapability,
config?: AgentPromptsConfig, config?: AgentPromptsConfig,
options: ResolveAgentPromptOptions = {},
): string { ): string {
const assignedId = config?.roleAssignments?.[role]; const assignedId = config?.roleAssignments?.[role];
@@ -1388,11 +1418,20 @@ export function resolveAgentPrompt(
); );
} }
if (role === "triage" && template.builtIn && template.id === "default-triage") {
return `${TRIAGE_PROMPT_TEXT}\n\n${buildTriageHeartbeatGuidance(options)}`;
}
if (role === "triage" && template.builtIn && template.id === "concise-triage") {
return `${CONCISE_TRIAGE_PROMPT_TEXT}\n\n${buildConciseTriageHeartbeatGuidance(options)}`;
}
return template.prompt; return template.prompt;
} }
// Fall back to built-in default for the role // Fall back to built-in default for the role
const builtIn = BUILTIN_AGENT_PROMPTS.find((t) => t.role === role && t.id === `default-${role}`); const builtIn = BUILTIN_AGENT_PROMPTS.find((t) => t.role === role && t.id === `default-${role}`);
if (role === "triage" && builtIn?.id === "default-triage") {
return `${TRIAGE_PROMPT_TEXT}\n\n${buildTriageHeartbeatGuidance(options)}`;
}
return builtIn?.prompt ?? ""; return builtIn?.prompt ?? "";
} }

View File

@@ -503,6 +503,8 @@ export const BUILTIN_REVIEW_REVISION_SETTINGS: WorkflowSettingDefinition[] = [
*/ */
export const DEFAULT_PLANNER_OVERSEER_EXECUTOR_STUCK_AFTER_MS = 2 * 60 * 60 * 1000; export const DEFAULT_PLANNER_OVERSEER_EXECUTOR_STUCK_AFTER_MS = 2 * 60 * 60 * 1000;
export const PLANNER_HEARTBEAT_PATROL_ENABLED_SETTING_ID = "plannerHeartbeatPatrolEnabled";
export const BUILTIN_OVERSIGHT_SETTINGS: WorkflowSettingDefinition[] = [ export const BUILTIN_OVERSIGHT_SETTINGS: WorkflowSettingDefinition[] = [
{ {
id: "plannerOversightLevel", id: "plannerOversightLevel",
@@ -575,6 +577,18 @@ export const BUILTIN_OVERSIGHT_SETTINGS: WorkflowSettingDefinition[] = [
description: description:
"Model id for the planner overseer session advisor. Used only when Session advisor (LLM) is enabled. Must be set together with Session advisor model provider.", "Model id for the planner overseer session advisor. Used only when Session advisor (LLM) is enabled. Must be set together with Session advisor model provider.",
}, },
/*
* FNXC:HeartbeatPatrol 2026-07-14-23:35:
* Idle no-task heartbeat patrol creates net-new work while plannerOversightLevel recovers tasks already in flight. Keep patrol as its own workflow setting so operators can reduce autonomous task creation volume without disabling stuck-task observation, steering, or recovery.
*/
{
id: PLANNER_HEARTBEAT_PATROL_ENABLED_SETTING_ID,
name: "Planner heartbeat patrol enabled",
type: "boolean",
default: true,
description:
"Enable idle/no-task heartbeat proactive patrol guidance that encourages agents to create or delegate new follow-up tasks. Disable to keep idle agents to assigned work, direct messages, explicit operator requests, and safe read-only/logging coordination without disabling planner overseer stuck-task recovery.",
},
]; ];
export const BUILTIN_WORKFLOW_SETTINGS: WorkflowSettingDefinition[] = [ export const BUILTIN_WORKFLOW_SETTINGS: WorkflowSettingDefinition[] = [

View File

@@ -174,6 +174,9 @@ export * from "./shared-mesh-state.js";
export { export {
BUILTIN_AGENT_PROMPTS, BUILTIN_AGENT_PROMPTS,
resolveAgentPrompt, resolveAgentPrompt,
buildTriageHeartbeatGuidance,
buildConciseTriageHeartbeatGuidance,
TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION,
getAvailableTemplates, getAvailableTemplates,
getTemplatesForRole, getTemplatesForRole,
} from "./agent-prompts.js"; } from "./agent-prompts.js";
@@ -267,6 +270,7 @@ export {
BUILTIN_TRIAGE_POLICY_SETTINGS, BUILTIN_TRIAGE_POLICY_SETTINGS,
BUILTIN_OVERSIGHT_SETTINGS, BUILTIN_OVERSIGHT_SETTINGS,
DEFAULT_PLANNER_OVERSEER_EXECUTOR_STUCK_AFTER_MS, DEFAULT_PLANNER_OVERSEER_EXECUTOR_STUCK_AFTER_MS,
PLANNER_HEARTBEAT_PATROL_ENABLED_SETTING_ID,
renderTriagePolicyPlaceholders, renderTriagePolicyPlaceholders,
} from "./builtin-workflow-settings.js"; } from "./builtin-workflow-settings.js";
export { export {
@@ -497,6 +501,7 @@ export {
resolveEffectiveSettingsById, resolveEffectiveSettingsById,
resolveOptionalReviewRevisionBudget, resolveOptionalReviewRevisionBudget,
resolveEffectivePlannerOversightLevel, resolveEffectivePlannerOversightLevel,
resolveEffectivePlannerHeartbeatPatrolEnabled,
PLAN_REVIEW_MAX_REVISIONS_SETTING_ID, PLAN_REVIEW_MAX_REVISIONS_SETTING_ID,
CODE_REVIEW_MAX_REVISIONS_SETTING_ID, CODE_REVIEW_MAX_REVISIONS_SETTING_ID,
type WorkflowSettingsResolverStore, type WorkflowSettingsResolverStore,

View File

@@ -161,6 +161,9 @@ export * from "./shared-mesh-state.js";
export { export {
BUILTIN_AGENT_PROMPTS, BUILTIN_AGENT_PROMPTS,
resolveAgentPrompt, resolveAgentPrompt,
buildTriageHeartbeatGuidance,
buildConciseTriageHeartbeatGuidance,
TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION,
getAvailableTemplates, getAvailableTemplates,
getTemplatesForRole, getTemplatesForRole,
FUSION_RUNTIME_SELF_AWARENESS, FUSION_RUNTIME_SELF_AWARENESS,
@@ -256,6 +259,7 @@ export {
BUILTIN_TRIAGE_POLICY_SETTINGS, BUILTIN_TRIAGE_POLICY_SETTINGS,
BUILTIN_OVERSIGHT_SETTINGS, BUILTIN_OVERSIGHT_SETTINGS,
DEFAULT_PLANNER_OVERSEER_EXECUTOR_STUCK_AFTER_MS, DEFAULT_PLANNER_OVERSEER_EXECUTOR_STUCK_AFTER_MS,
PLANNER_HEARTBEAT_PATROL_ENABLED_SETTING_ID,
renderTriagePolicyPlaceholders, renderTriagePolicyPlaceholders,
} from "./builtin-workflow-settings.js"; } from "./builtin-workflow-settings.js";
export { export {
@@ -486,6 +490,7 @@ export {
resolveEffectiveSettingsById, resolveEffectiveSettingsById,
resolveOptionalReviewRevisionBudget, resolveOptionalReviewRevisionBudget,
resolveEffectivePlannerOversightLevel, resolveEffectivePlannerOversightLevel,
resolveEffectivePlannerHeartbeatPatrolEnabled,
PLAN_REVIEW_MAX_REVISIONS_SETTING_ID, PLAN_REVIEW_MAX_REVISIONS_SETTING_ID,
CODE_REVIEW_MAX_REVISIONS_SETTING_ID, CODE_REVIEW_MAX_REVISIONS_SETTING_ID,
type WorkflowSettingsResolverStore, type WorkflowSettingsResolverStore,

View File

@@ -33,7 +33,7 @@ import {
type WorkflowIrResolverStore, type WorkflowIrResolverStore,
} from "./workflow-ir-resolver.js"; } from "./workflow-ir-resolver.js";
import { resolveEffectiveSettingValues, findOrphanedSettingValues } from "./workflow-settings.js"; import { resolveEffectiveSettingValues, findOrphanedSettingValues } from "./workflow-settings.js";
import { BUILTIN_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js"; import { BUILTIN_WORKFLOW_SETTINGS, PLANNER_HEARTBEAT_PATROL_ENABLED_SETTING_ID } from "./builtin-workflow-settings.js";
import type { WorkflowSettingDefinition, WorkflowIr, WorkflowOptionalGroupConfig } from "./workflow-ir-types.js"; import type { WorkflowSettingDefinition, WorkflowIr, WorkflowOptionalGroupConfig } from "./workflow-ir-types.js";
import { PLANNER_OVERSIGHT_LEVELS, DEFAULT_PLANNER_OVERSIGHT_LEVEL, type PlannerOversightLevel } from "./types.js"; import { PLANNER_OVERSIGHT_LEVELS, DEFAULT_PLANNER_OVERSIGHT_LEVEL, type PlannerOversightLevel } from "./types.js";
@@ -259,3 +259,16 @@ export function resolveEffectivePlannerOversightLevel(
} }
return DEFAULT_PLANNER_OVERSIGHT_LEVEL; return DEFAULT_PLANNER_OVERSIGHT_LEVEL;
} }
/*
* FNXC:HeartbeatPatrol 2026-07-14-23:36:
* Boolean workflow values can arrive from built-in declaration defaults or explicit stored overrides. Normalize the idle-heartbeat patrol flag in one place so engine prompt code preserves default-on compatibility while treating only explicit false as no-patrol.
*/
export function resolveEffectivePlannerHeartbeatPatrolEnabled(
workflowEffective: Record<string, unknown> | boolean | null | undefined,
): boolean {
const value = typeof workflowEffective === "object" && workflowEffective !== null
? workflowEffective[PLANNER_HEARTBEAT_PATROL_ENABLED_SETTING_ID]
: workflowEffective;
return value !== false;
}

View File

@@ -13,7 +13,16 @@ import {
} from "../agent-heartbeat.js"; } from "../agent-heartbeat.js";
import { AgentLogger } from "../agent-logger.js"; import { AgentLogger } from "../agent-logger.js";
import { expectAppendAgentLog } from "./agent-log-assertions.js"; import { expectAppendAgentLog } from "./agent-log-assertions.js";
import type { AgentStore, AgentHeartbeatRun, TaskStore, TaskDetail, Agent, MessageStore, Message } from "@fusion/core"; import {
TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION,
type AgentStore,
type AgentHeartbeatRun,
type TaskStore,
type TaskDetail,
type Agent,
type MessageStore,
type Message,
} from "@fusion/core";
import { createMessage, createBudgetStatus } from "./heartbeat-test-helpers.js"; import { createMessage, createBudgetStatus } from "./heartbeat-test-helpers.js";
vi.mock("../logger.js", async () => { vi.mock("../logger.js", async () => {
const { createMockLogger, formatMockError } = await import("./heartbeat-test-helpers.js"); const { createMockLogger, formatMockError } = await import("./heartbeat-test-helpers.js");
@@ -1592,6 +1601,48 @@ describe("executeHeartbeat", () => {
expect(systemPrompt).toContain("fn_heartbeat_done"); expect(systemPrompt).toContain("fn_heartbeat_done");
}); });
it("no-task run gates proactive patrol prompts from workflow setting", async () => {
for (const [storedValue, shouldPatrol] of [[undefined, true], [true, true], [false, false]] as const) {
vi.clearAllMocks();
const store = createStoreWithAgentForExec({ taskId: undefined, soul: "I am a coordinator" });
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const taskStore = createMockTaskStore({
getSettings: vi.fn().mockResolvedValue({ defaultWorkflowId: "builtin:coding" }),
getWorkflowSettingsProjectId: vi.fn().mockReturnValue("test-project"),
getWorkflowSettingValues: vi.fn().mockReturnValue(
storedValue === undefined ? {} : { plannerHeartbeatPatrolEnabled: storedValue },
),
} as Partial<TaskStore>);
const monitor = new HeartbeatMonitor({ store, taskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
expect(result.status).toBe("completed");
expect(mockedCreateFnAgent).toHaveBeenCalledOnce();
const callArgs = mockedCreateFnAgent.mock.calls[0]![0]!;
const systemPrompt = callArgs.systemPrompt;
const executionPrompt = mockSession.prompt.mock.calls.at(-1)?.[0] ?? "";
const savedRun = await store.getRunDetail("agent-001", result.id);
if (shouldPatrol) {
expect(systemPrompt).toContain("Use fn_task_create to spawn follow-up work");
expect(executionPrompt).toContain("create a focused task instead of attempting unscheduled implementation");
expect(savedRun?.systemPrompt).toContain("Use fn_task_create to spawn follow-up work");
expect(savedRun?.executionPrompt).toContain("create a focused task instead of attempting unscheduled implementation");
expect(systemPrompt).not.toContain(TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION);
} else {
expect(systemPrompt).toContain(TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION);
expect(executionPrompt).toContain(TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION);
expect(savedRun?.systemPrompt).toContain(TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION);
expect(savedRun?.executionPrompt).toContain(TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION);
expect(systemPrompt).not.toContain("Use fn_task_create to spawn follow-up work");
expect(executionPrompt).not.toContain("create a focused task instead of attempting unscheduled implementation");
}
}
});
it("identity agent without task receives no-task execution prompt mentioning 'no assigned task'", async () => { it("identity agent without task receives no-task execution prompt mentioning 'no assigned task'", async () => {
const store = createStoreWithAgentForExec({ taskId: undefined, soul: "I am a coordinator" }); const store = createStoreWithAgentForExec({ taskId: undefined, soul: "I am a coordinator" });
const mockSession = createMockAgentSession(); const mockSession = createMockAgentSession();
@@ -3524,7 +3575,7 @@ describe("executeHeartbeat", () => {
expect(taskLogTool.name).toBe("fn_task_log"); expect(taskLogTool.name).toBe("fn_task_log");
}); });
it("passes execution settings model ahead of stale runtime model", async () => { it("uses complete assigned runtime model ahead of shared execution settings", async () => {
const store = createStoreWithAgentForExec({ const store = createStoreWithAgentForExec({
runtimeConfig: { model: "anthropic/claude-sonnet-4-5" }, runtimeConfig: { model: "anthropic/claude-sonnet-4-5" },
}); });
@@ -3545,8 +3596,8 @@ describe("executeHeartbeat", () => {
expect(mockedCreateFnAgent).toHaveBeenCalledOnce(); expect(mockedCreateFnAgent).toHaveBeenCalledOnce();
const callArgs = mockedCreateFnAgent.mock.calls[0]![0]; const callArgs = mockedCreateFnAgent.mock.calls[0]![0];
expect(callArgs.defaultProvider).toBe("openai"); expect(callArgs.defaultProvider).toBe("anthropic");
expect(callArgs.defaultModelId).toBe("gpt-4.1"); expect(callArgs.defaultModelId).toBe("claude-sonnet-4-5");
expect(callArgs.fallbackProvider).toBeUndefined(); expect(callArgs.fallbackProvider).toBeUndefined();
expect(callArgs.fallbackModelId).toBeUndefined(); expect(callArgs.fallbackModelId).toBeUndefined();
}); });

View File

@@ -33,6 +33,9 @@ import {
AWAITING_APPROVAL_PAUSE_REASON, AWAITING_APPROVAL_PAUSE_REASON,
rankAssignedTasksForWakeDelta, rankAssignedTasksForWakeDelta,
formatAssignedTasksWakeDeltaSection, formatAssignedTasksWakeDeltaSection,
resolveEffectiveSettingsById,
resolveEffectivePlannerHeartbeatPatrolEnabled,
TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION,
} from "@fusion/core"; } from "@fusion/core";
import type { ToolDefinition } from "@earendil-works/pi-coding-agent"; import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
import { Type, type Static } from "@earendil-works/pi-ai"; import { Type, type Static } from "@earendil-works/pi-ai";
@@ -109,6 +112,27 @@ function adjustHeartbeatMemoryPrimer(basePrompt: string, mode: AgentMemoryInclus
); );
} }
async function resolveNoTaskHeartbeatPatrolEnabled(
taskStore: TaskStore,
settings: Settings | undefined,
): Promise<boolean> {
try {
const projectId = typeof taskStore.getWorkflowSettingsProjectId === "function"
? taskStore.getWorkflowSettingsProjectId()
: "default";
const workflowId = settings?.defaultWorkflowId || "builtin:coding";
/*
FNXC:HeartbeatPatrol 2026-07-15-00:10:
No-task heartbeats have no task-selected workflow, so idle patrol policy resolves through the project default workflow. If a project has not selected one, built-in coding supplies the compatibility default (`plannerHeartbeatPatrolEnabled: true`). This keeps idle-agent patrol separate from per-task planner oversight recovery.
*/
const effective = await resolveEffectiveSettingsById(taskStore, workflowId, projectId);
return resolveEffectivePlannerHeartbeatPatrolEnabled(effective);
} catch (error: unknown) {
heartbeatLog.warn(`Failed to resolve no-task heartbeat patrol setting: ${error instanceof Error ? error.message : String(error)} — defaulting enabled`);
return true;
}
}
interface SelfImproveServiceLike { interface SelfImproveServiceLike {
shouldRunSelfImprove(agentId: string): Promise<boolean>; shouldRunSelfImprove(agentId: string): Promise<boolean>;
getSelfImprovePrompt(agentId: string): Promise<string>; getSelfImprovePrompt(agentId: string): Promise<string>;
@@ -662,6 +686,45 @@ When sending messages:
- Include relevant context (task IDs, file paths) in metadata when applicable. - Include relevant context (task IDs, file paths) in metadata when applicable.
- Use agent-to-agent for inter-agent communication.`; - Use agent-to-agent for inter-agent communication.`;
/*
FNXC:HeartbeatPatrol 2026-07-15-00:09:
Operators need to disable idle/no-task proactive task creation without disabling planner oversight for tasks already in flight. Keep the exported legacy constants as the default patrol-on prompt, and render patrol-off variants only when the workflow setting is explicitly false so existing callers remain compatible.
*/
export function renderHeartbeatNoTaskSystemPrompt(options: { plannerHeartbeatPatrolEnabled?: boolean } = {}): string {
if (options.plannerHeartbeatPatrolEnabled !== false) {
return HEARTBEAT_NO_TASK_SYSTEM_PROMPT;
}
return HEARTBEAT_NO_TASK_SYSTEM_PROMPT
.replace(
"2. Do ONE useful action: analyze, create follow-up tasks, delegate work, or update memory.",
"2. Do ONE useful action: analyze, respond to direct messages or explicit operator requests, delegate already-requested work, or update memory.",
)
.replace(
"4. Use fn_task_create to spawn follow-up work — but first scan the board/context for an existing open task covering the same work; do not duplicate.",
`4. ${TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION}`,
)
.replace(
"- DO: create a clearly scoped task for a newly discovered reliability issue.\n",
"",
)
.replace(
"- **fn_task_create:** create executable work when ownership is not predetermined.",
`- **Idle patrol disabled:** ${TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION}`,
)
.replace(
"If unsure who should do the work, prefer fn_task_create and let scheduler routing happen naturally.",
"If unsure who should do the work, do not create a patrol task; no-op with reason, handle an explicit request, or ask for clarification when available.",
)
.replace(
"- **Unowned risk discovered:** create one focused task with concrete acceptance language.",
"- **Unowned risk discovered:** do not create a patrol task; record durable context only when it is safe and useful, or wait for explicit operator direction.",
)
.replace(
"- **Message requests action:** reply first, then create/delegate follow-up work when execution is required.",
"- **Message requests action:** reply first, then delegate only when ownership is clear or create follow-up work only when the message/operator explicitly requests it.",
);
}
// Backward-compatible alias; prefer HEARTBEAT_NO_TASK_SYSTEM_PROMPT. // Backward-compatible alias; prefer HEARTBEAT_NO_TASK_SYSTEM_PROMPT.
export const HEARTBEAT_SYSTEM_PROMPT_NO_TASK = HEARTBEAT_NO_TASK_SYSTEM_PROMPT; export const HEARTBEAT_SYSTEM_PROMPT_NO_TASK = HEARTBEAT_NO_TASK_SYSTEM_PROMPT;
@@ -881,6 +944,36 @@ export const HEARTBEAT_NO_TASK_PROCEDURE_OFF = `## Heartbeat Procedure (run ever
Critical: a heartbeat without observable progress (or an explicit no-op reason) is a bug.`; Critical: a heartbeat without observable progress (or an explicit no-op reason) is a bug.`;
export function renderHeartbeatNoTaskProcedure(
procedure: string,
options: { plannerHeartbeatPatrolEnabled?: boolean } = {},
): string {
if (options.plannerHeartbeatPatrolEnabled !== false) {
return procedure;
}
return procedure
.replace(
" - **Implementation-scope discovery:** code/product work that needs a task;\n create a focused task instead of attempting unscheduled implementation.",
` - **Implementation-scope discovery:** code/product work that needs a task;\n ${TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION}`,
)
.replace(
"6. **Pick the next concrete action** — exactly ONE useful action this heartbeat:\n create a focused task, delegate work, send/reply to a message, or append\n durable memory. Never retry checkout/claim conflicts.",
"6. **Pick the next concrete action** — exactly ONE useful action this heartbeat:\n respond to direct messages, delegate explicitly requested work, append durable\n memory, or no-op with reason. Never retry checkout/claim conflicts.",
)
.replace(
"7. **Persist progress** — use available ambient tools only:\n fn_task_create, fn_delegate_task, fn_send_message, fn_memory_append.",
"7. **Persist progress** — use available ambient tools only for non-patrol work:\n fn_delegate_task, fn_send_message, fn_memory_append, or an explicit no-op reason.\n Do not call fn_task_create for idle patrol task creation.",
)
.replace(
"8. **Final disposition checklist** — acted with evidence / follow-up created or\n delegated / explicit no-op with reason.",
"8. **Final disposition checklist** — acted with evidence / delegated explicit\n requested work / explicit no-op with reason.",
)
.replace(
"Critical: a heartbeat without observable progress (a created task, delegation,\nmessage reply, memory append, or explicit \"no-op with reason\") is a bug.",
"Critical: a heartbeat without observable progress (delegation for explicit work,\nmessage reply, memory append, or explicit \"no-op with reason\") is a bug.",
);
}
// Backward-compatible alias; prefer HEARTBEAT_NO_TASK_PROCEDURE_STRICT. // Backward-compatible alias; prefer HEARTBEAT_NO_TASK_PROCEDURE_STRICT.
export const HEARTBEAT_NO_TASK_PROCEDURE = HEARTBEAT_NO_TASK_PROCEDURE_STRICT; export const HEARTBEAT_NO_TASK_PROCEDURE = HEARTBEAT_NO_TASK_PROCEDURE_STRICT;
@@ -2851,8 +2944,13 @@ export class HeartbeatMonitor {
globalSettings: memorySettings, globalSettings: memorySettings,
}); });
const priorMemoryMode = agent.runtimeConfig?.lastAgentMemoryInclusionMode; const priorMemoryMode = agent.runtimeConfig?.lastAgentMemoryInclusionMode;
const plannerHeartbeatPatrolEnabled = isNoTaskRun
? await resolveNoTaskHeartbeatPatrolEnabled(taskStore, heartbeatModelSettings)
: true;
const baseHeartbeatSystemPrompt = adjustHeartbeatMemoryPrimer( const baseHeartbeatSystemPrompt = adjustHeartbeatMemoryPrimer(
isNoTaskRun ? HEARTBEAT_NO_TASK_SYSTEM_PROMPT : HEARTBEAT_SYSTEM_PROMPT, isNoTaskRun
? renderHeartbeatNoTaskSystemPrompt({ plannerHeartbeatPatrolEnabled })
: HEARTBEAT_SYSTEM_PROMPT,
resolvedMemoryMode.mode, resolvedMemoryMode.mode,
); );
let resolvedInstructionsText = ""; let resolvedInstructionsText = "";
@@ -3326,9 +3424,12 @@ export class HeartbeatMonitor {
off: HEARTBEAT_NO_TASK_PROCEDURE_OFF, off: HEARTBEAT_NO_TASK_PROCEDURE_OFF,
}, },
}); });
const heartbeatProcedureText = shouldOverrideCustomProcedureForNoTaskRun const rawHeartbeatProcedureText = shouldOverrideCustomProcedureForNoTaskRun
? resolvedProcedureTemplate ? resolvedProcedureTemplate
: (customProcedure ?? resolvedProcedureTemplate); : (customProcedure ?? resolvedProcedureTemplate);
const heartbeatProcedureText = isNoTaskRun
? renderHeartbeatNoTaskProcedure(rawHeartbeatProcedureText, { plannerHeartbeatPatrolEnabled })
: rawHeartbeatProcedureText;
// Precedence: heartbeatProcedurePath (custom file) > resolved heartbeatScopeDiscipline template > strict default. // Precedence: heartbeatProcedurePath (custom file) > resolved heartbeatScopeDiscipline template > strict default.
const heartbeatProcedureSource = shouldOverrideCustomProcedureForNoTaskRun const heartbeatProcedureSource = shouldOverrideCustomProcedureForNoTaskRun
? "default-no-task-override" ? "default-no-task-override"
@@ -3390,6 +3491,28 @@ export class HeartbeatMonitor {
), ),
] ]
: []; : [];
const noTaskActionGuidanceLines = plannerHeartbeatPatrolEnabled
? [
"2. **Create new tasks** — Use fn_task_create for net-new executable work.",
" Prefer concrete tasks with clear outcomes; avoid vague placeholders.",
"",
]
: [
"2. **Idle patrol disabled** — Do not create new tasks during idle/no-task heartbeats.",
" Only handle assigned work, direct messages, explicit operator requests, and safe read-only/logging coordination.",
"",
];
const noTaskFlowGuidanceLines = plannerHeartbeatPatrolEnabled
? [
"5. **Monitor project flow** — Review board/project signals and surface issues",
" by creating or delegating follow-up work as appropriate.",
"",
]
: [
"5. **Monitor project flow** — Review board/project signals only for safe coordination.",
" Do not spawn patrol tasks from idle observations; no-op with reason when no explicit action is needed.",
"",
];
executionPrompt = [ executionPrompt = [
`Heartbeat execution for agent "${agent.name}" (ID: ${agent.id})`, `Heartbeat execution for agent "${agent.name}" (ID: ${agent.id})`,
@@ -3424,18 +3547,14 @@ export class HeartbeatMonitor {
"1. **Check your messages** — Use fn_read_messages to review pending messages.", "1. **Check your messages** — Use fn_read_messages to review pending messages.",
" If replying, use fn_send_message and include reply_to_message_id so threads stay linked.", " If replying, use fn_send_message and include reply_to_message_id so threads stay linked.",
"", "",
"2. **Create new tasks** — Use fn_task_create for net-new executable work.", ...noTaskActionGuidanceLines,
" Prefer concrete tasks with clear outcomes; avoid vague placeholders.",
"",
"3. **Delegate work** — Use fn_list_agents to find available specialists, then", "3. **Delegate work** — Use fn_list_agents to find available specialists, then",
" fn_delegate_task when immediate ownership by a specific agent is beneficial.", " fn_delegate_task when immediate ownership by a specific agent is beneficial.",
"", "",
"4. **Update memory** — Use fn_memory_append for durable, reusable learnings", "4. **Update memory** — Use fn_memory_append for durable, reusable learnings",
" (conventions, pitfalls, architecture constraints), not transient chatter.", " (conventions, pitfalls, architecture constraints), not transient chatter.",
"", "",
"5. **Monitor project flow** — Review board/project signals and surface issues", ...noTaskFlowGuidanceLines,
" by creating or delegating follow-up work as appropriate.",
"",
"When auto-claim relevant tasks is enabled, review Open Task Candidates above and", "When auto-claim relevant tasks is enabled, review Open Task Candidates above and",
"prioritize tasks that align with your role and soul before creating net-new tasks.", "prioritize tasks that align with your role and soul before creating net-new tasks.",
...candidateLines, ...candidateLines,

View File

@@ -20,6 +20,8 @@ import {
resolveAgentPrompt, resolveAgentPrompt,
builtinSeamPrompt, builtinSeamPrompt,
renderTriagePolicyPlaceholders, renderTriagePolicyPlaceholders,
resolveEffectiveSettings,
resolveEffectivePlannerHeartbeatPatrolEnabled,
resolveTaskPlanningPrompt, resolveTaskPlanningPrompt,
resolveTaskSeamPrompt, resolveTaskSeamPrompt,
resolvePersistAgentThinkingLog, resolvePersistAgentThinkingLog,
@@ -1142,11 +1144,13 @@ export class TriageProcessor {
const workflowFastPlanningPrompt = leanPlanning const workflowFastPlanningPrompt = leanPlanning
? await resolveTaskSeamPrompt(this.store, task.id, "planning-fast").catch(() => undefined) ? await resolveTaskSeamPrompt(this.store, task.id, "planning-fast").catch(() => undefined)
: undefined; : undefined;
const effectiveWorkflowSettings = await resolveEffectiveSettings(this.store, task).catch(() => ({}));
const plannerHeartbeatPatrolEnabled = resolveEffectivePlannerHeartbeatPatrolEnabled(effectiveWorkflowSettings);
// FN-6232: standard-mode built-in triage policy is sourced from the workflow IR planning node; the former engine duplicate was removed. // FN-6232: standard-mode built-in triage policy is sourced from the workflow IR planning node; the former engine duplicate was removed.
const userTriagePrompt = settings.agentPrompts?.roleAssignments?.triage const userTriagePrompt = settings.agentPrompts?.roleAssignments?.triage
? resolveAgentPrompt("triage", settings.agentPrompts) ? resolveAgentPrompt("triage", settings.agentPrompts, { plannerHeartbeatPatrolEnabled })
: ""; : "";
const defaultTriagePrompt = resolveAgentPrompt("triage"); const defaultTriagePrompt = resolveAgentPrompt("triage", undefined, { plannerHeartbeatPatrolEnabled });
const resolvedBasePrompt = userTriagePrompt const resolvedBasePrompt = userTriagePrompt
|| (leanPlanning || (leanPlanning
? (workflowFastPlanningPrompt || builtinSeamPrompt("planning-fast") || defaultTriagePrompt) ? (workflowFastPlanningPrompt || builtinSeamPrompt("planning-fast") || defaultTriagePrompt)