From de25e32eac9ff8f33ccd2d008300ffda54b537f0 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 15 Jul 2026 12:27:15 -0700 Subject: [PATCH] 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) --- .../fn-7963-planner-heartbeat-patrol.md | 7 + docs/settings-reference.md | 11 +- .../core/src/__tests__/agent-prompts.test.ts | 29 ++++ .../builtin-workflow-settings-triage.test.ts | 21 +++ ...nnerHeartbeatPatrolEnabled-default.test.ts | 64 +++++++++ packages/core/src/agent-prompts.ts | 55 +++++-- .../core/src/builtin-workflow-settings.ts | 14 ++ packages/core/src/index.gate.ts | 5 + packages/core/src/index.ts | 5 + .../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(-) create mode 100644 .changeset/fn-7963-planner-heartbeat-patrol.md create mode 100644 packages/core/src/__tests__/plannerHeartbeatPatrolEnabled-default.test.ts diff --git a/.changeset/fn-7963-planner-heartbeat-patrol.md b/.changeset/fn-7963-planner-heartbeat-patrol.md new file mode 100644 index 0000000000..ea3aaa1386 --- /dev/null +++ b/.changeset/fn-7963-planner-heartbeat-patrol.md @@ -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. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 085bb6cbf3..2ffd845c9e 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -295,8 +295,9 @@ Actions. It has two tabs: automatic revisions, enter a non-negative integer to cap attempts, or enter `0` to disable automatic revision for that path. `plannerOversightLevel` is the workflow-native planner oversight mode and accepts `off`, `observe`, `steer`, - or `autonomous` (default). Edits batch and commit through a single **Save** in - the Values tab. + or `autonomous` (default). `plannerHeartbeatPatrolEnabled` controls idle/no-task + 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 `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` | | **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. | ### 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: `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. @@ -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`. | | `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)". | +| `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. diff --git a/packages/core/src/__tests__/agent-prompts.test.ts b/packages/core/src/__tests__/agent-prompts.test.ts index e37b7d51cb..eac9b62bb6 100644 --- a/packages/core/src/__tests__/agent-prompts.test.ts +++ b/packages/core/src/__tests__/agent-prompts.test.ts @@ -8,6 +8,7 @@ import { getAvailableTemplates, getTemplatesForRole, FUSION_RUNTIME_SELF_AWARENESS, + TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION, } from "../agent-prompts.js"; import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js"; import { BUILTIN_SEAM_PROMPTS, builtinSeamPrompt } from "../builtin-workflow-prompts.js"; @@ -33,6 +34,34 @@ describe("resolveAgentPrompt", () => { 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", () => { const result = resolveAgentPrompt("reviewer"); expect(result).toBeTruthy(); diff --git a/packages/core/src/__tests__/builtin-workflow-settings-triage.test.ts b/packages/core/src/__tests__/builtin-workflow-settings-triage.test.ts index d5fa31f554..93c2c1a9f0 100644 --- a/packages/core/src/__tests__/builtin-workflow-settings-triage.test.ts +++ b/packages/core/src/__tests__/builtin-workflow-settings-triage.test.ts @@ -93,6 +93,7 @@ describe("workflow-native built-in workflow settings", () => { "plannerOverseerAdvisorEnabled", "plannerOverseerAdvisorProvider", "plannerOverseerAdvisorModelId", + "plannerHeartbeatPatrolEnabled", ]); // FNXC:PlannerOversight 2026-07-14-12:00: LLM session advisor must default OFF. expect(BUILTIN_OVERSIGHT_SETTINGS.find((s) => s.id === "plannerOverseerAdvisorEnabled")).toMatchObject({ @@ -174,6 +175,26 @@ describe("workflow-native built-in workflow settings", () => { movedKeyIds.has("plannerOverseerExecutorStuckAfterMs"), "plannerOverseerExecutorStuckAfterMs should not be in MOVED_SETTINGS_KEYS", ).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", () => { diff --git a/packages/core/src/__tests__/plannerHeartbeatPatrolEnabled-default.test.ts b/packages/core/src/__tests__/plannerHeartbeatPatrolEnabled-default.test.ts new file mode 100644 index 0000000000..a0ae9ac8ef --- /dev/null +++ b/packages/core/src/__tests__/plannerHeartbeatPatrolEnabled-default.test.ts @@ -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): 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); + }); +}); diff --git a/packages/core/src/agent-prompts.ts b/packages/core/src/agent-prompts.ts index 8a05f3a97b..8500bc987b 100644 --- a/packages/core/src/agent-prompts.ts +++ b/packages/core/src/agent-prompts.ts @@ -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. - 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: 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. - 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. -- 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. +- ${noTaskLine}${patrolSafetyLines} - 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 @@ -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. - 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. - 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. -- 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. +- ${noTaskLine}${patrolSafetyLines} - Prefer crisp decisions, clear file scope, and concrete verification steps over narrative detail.`; +} + +const CONCISE_TRIAGE_HEARTBEAT_GUIDANCE = buildConciseTriageHeartbeatGuidance(); // --------------------------------------------------------------------------- // 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 * custom or built-in templates. */ +export interface ResolveAgentPromptOptions { + plannerHeartbeatPatrolEnabled?: boolean; +} + export function resolveAgentPrompt( role: AgentCapability, config?: AgentPromptsConfig, + options: ResolveAgentPromptOptions = {}, ): string { 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; } // Fall back to built-in default for the 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 ?? ""; } diff --git a/packages/core/src/builtin-workflow-settings.ts b/packages/core/src/builtin-workflow-settings.ts index 05c58c0dde..8a410bd5bd 100644 --- a/packages/core/src/builtin-workflow-settings.ts +++ b/packages/core/src/builtin-workflow-settings.ts @@ -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 PLANNER_HEARTBEAT_PATROL_ENABLED_SETTING_ID = "plannerHeartbeatPatrolEnabled"; + export const BUILTIN_OVERSIGHT_SETTINGS: WorkflowSettingDefinition[] = [ { id: "plannerOversightLevel", @@ -575,6 +577,18 @@ export const BUILTIN_OVERSIGHT_SETTINGS: WorkflowSettingDefinition[] = [ 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.", }, + /* + * 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[] = [ diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index f391373956..a083065a8a 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -174,6 +174,9 @@ export * from "./shared-mesh-state.js"; export { BUILTIN_AGENT_PROMPTS, resolveAgentPrompt, + buildTriageHeartbeatGuidance, + buildConciseTriageHeartbeatGuidance, + TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION, getAvailableTemplates, getTemplatesForRole, } from "./agent-prompts.js"; @@ -267,6 +270,7 @@ export { BUILTIN_TRIAGE_POLICY_SETTINGS, BUILTIN_OVERSIGHT_SETTINGS, DEFAULT_PLANNER_OVERSEER_EXECUTOR_STUCK_AFTER_MS, + PLANNER_HEARTBEAT_PATROL_ENABLED_SETTING_ID, renderTriagePolicyPlaceholders, } from "./builtin-workflow-settings.js"; export { @@ -497,6 +501,7 @@ export { resolveEffectiveSettingsById, resolveOptionalReviewRevisionBudget, resolveEffectivePlannerOversightLevel, + resolveEffectivePlannerHeartbeatPatrolEnabled, PLAN_REVIEW_MAX_REVISIONS_SETTING_ID, CODE_REVIEW_MAX_REVISIONS_SETTING_ID, type WorkflowSettingsResolverStore, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d0fb5f58d4..440266b77c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -161,6 +161,9 @@ export * from "./shared-mesh-state.js"; export { BUILTIN_AGENT_PROMPTS, resolveAgentPrompt, + buildTriageHeartbeatGuidance, + buildConciseTriageHeartbeatGuidance, + TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION, getAvailableTemplates, getTemplatesForRole, FUSION_RUNTIME_SELF_AWARENESS, @@ -256,6 +259,7 @@ export { BUILTIN_TRIAGE_POLICY_SETTINGS, BUILTIN_OVERSIGHT_SETTINGS, DEFAULT_PLANNER_OVERSEER_EXECUTOR_STUCK_AFTER_MS, + PLANNER_HEARTBEAT_PATROL_ENABLED_SETTING_ID, renderTriagePolicyPlaceholders, } from "./builtin-workflow-settings.js"; export { @@ -486,6 +490,7 @@ export { resolveEffectiveSettingsById, resolveOptionalReviewRevisionBudget, resolveEffectivePlannerOversightLevel, + resolveEffectivePlannerHeartbeatPatrolEnabled, PLAN_REVIEW_MAX_REVISIONS_SETTING_ID, CODE_REVIEW_MAX_REVISIONS_SETTING_ID, type WorkflowSettingsResolverStore, diff --git a/packages/core/src/workflow-settings-resolver.ts b/packages/core/src/workflow-settings-resolver.ts index bc3392d17c..dc84cdac02 100644 --- a/packages/core/src/workflow-settings-resolver.ts +++ b/packages/core/src/workflow-settings-resolver.ts @@ -33,7 +33,7 @@ import { type WorkflowIrResolverStore, } from "./workflow-ir-resolver.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 { PLANNER_OVERSIGHT_LEVELS, DEFAULT_PLANNER_OVERSIGHT_LEVEL, type PlannerOversightLevel } from "./types.js"; @@ -259,3 +259,16 @@ export function resolveEffectivePlannerOversightLevel( } 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 | boolean | null | undefined, +): boolean { + const value = typeof workflowEffective === "object" && workflowEffective !== null + ? workflowEffective[PLANNER_HEARTBEAT_PATROL_ENABLED_SETTING_ID] + : workflowEffective; + return value !== false; +} diff --git a/packages/engine/src/__tests__/heartbeat-executor.test.ts b/packages/engine/src/__tests__/heartbeat-executor.test.ts index 8b0d34e2fb..5e01706377 100644 --- a/packages/engine/src/__tests__/heartbeat-executor.test.ts +++ b/packages/engine/src/__tests__/heartbeat-executor.test.ts @@ -13,7 +13,16 @@ import { } from "../agent-heartbeat.js"; import { AgentLogger } from "../agent-logger.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"; vi.mock("../logger.js", async () => { const { createMockLogger, formatMockError } = await import("./heartbeat-test-helpers.js"); @@ -1592,6 +1601,48 @@ describe("executeHeartbeat", () => { 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); + + 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 () => { const store = createStoreWithAgentForExec({ taskId: undefined, soul: "I am a coordinator" }); const mockSession = createMockAgentSession(); @@ -3524,7 +3575,7 @@ describe("executeHeartbeat", () => { 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({ runtimeConfig: { model: "anthropic/claude-sonnet-4-5" }, }); @@ -3545,8 +3596,8 @@ describe("executeHeartbeat", () => { expect(mockedCreateFnAgent).toHaveBeenCalledOnce(); const callArgs = mockedCreateFnAgent.mock.calls[0]![0]; - expect(callArgs.defaultProvider).toBe("openai"); - expect(callArgs.defaultModelId).toBe("gpt-4.1"); + expect(callArgs.defaultProvider).toBe("anthropic"); + expect(callArgs.defaultModelId).toBe("claude-sonnet-4-5"); expect(callArgs.fallbackProvider).toBeUndefined(); expect(callArgs.fallbackModelId).toBeUndefined(); }); diff --git a/packages/engine/src/agent-heartbeat.ts b/packages/engine/src/agent-heartbeat.ts index d8b4487abe..1ddb92b5dc 100644 --- a/packages/engine/src/agent-heartbeat.ts +++ b/packages/engine/src/agent-heartbeat.ts @@ -33,6 +33,9 @@ import { AWAITING_APPROVAL_PAUSE_REASON, rankAssignedTasksForWakeDelta, formatAssignedTasksWakeDeltaSection, + resolveEffectiveSettingsById, + resolveEffectivePlannerHeartbeatPatrolEnabled, + TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION, } from "@fusion/core"; import type { ToolDefinition } from "@earendil-works/pi-coding-agent"; 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 { + 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 { shouldRunSelfImprove(agentId: string): Promise; getSelfImprovePrompt(agentId: string): Promise; @@ -662,6 +686,45 @@ When sending messages: - Include relevant context (task IDs, file paths) in metadata when applicable. - 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. 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.`; +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. export const HEARTBEAT_NO_TASK_PROCEDURE = HEARTBEAT_NO_TASK_PROCEDURE_STRICT; @@ -2851,8 +2944,13 @@ export class HeartbeatMonitor { globalSettings: memorySettings, }); const priorMemoryMode = agent.runtimeConfig?.lastAgentMemoryInclusionMode; + const plannerHeartbeatPatrolEnabled = isNoTaskRun + ? await resolveNoTaskHeartbeatPatrolEnabled(taskStore, heartbeatModelSettings) + : true; const baseHeartbeatSystemPrompt = adjustHeartbeatMemoryPrimer( - isNoTaskRun ? HEARTBEAT_NO_TASK_SYSTEM_PROMPT : HEARTBEAT_SYSTEM_PROMPT, + isNoTaskRun + ? renderHeartbeatNoTaskSystemPrompt({ plannerHeartbeatPatrolEnabled }) + : HEARTBEAT_SYSTEM_PROMPT, resolvedMemoryMode.mode, ); let resolvedInstructionsText = ""; @@ -3326,9 +3424,12 @@ export class HeartbeatMonitor { off: HEARTBEAT_NO_TASK_PROCEDURE_OFF, }, }); - const heartbeatProcedureText = shouldOverrideCustomProcedureForNoTaskRun + const rawHeartbeatProcedureText = shouldOverrideCustomProcedureForNoTaskRun ? resolvedProcedureTemplate : (customProcedure ?? resolvedProcedureTemplate); + const heartbeatProcedureText = isNoTaskRun + ? renderHeartbeatNoTaskProcedure(rawHeartbeatProcedureText, { plannerHeartbeatPatrolEnabled }) + : rawHeartbeatProcedureText; // Precedence: heartbeatProcedurePath (custom file) > resolved heartbeatScopeDiscipline template > strict default. const heartbeatProcedureSource = shouldOverrideCustomProcedureForNoTaskRun ? "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 = [ `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.", " 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.", - " Prefer concrete tasks with clear outcomes; avoid vague placeholders.", - "", + ...noTaskActionGuidanceLines, "3. **Delegate work** — Use fn_list_agents to find available specialists, then", " fn_delegate_task when immediate ownership by a specific agent is beneficial.", "", "4. **Update memory** — Use fn_memory_append for durable, reusable learnings", " (conventions, pitfalls, architecture constraints), not transient chatter.", "", - "5. **Monitor project flow** — Review board/project signals and surface issues", - " by creating or delegating follow-up work as appropriate.", - "", + ...noTaskFlowGuidanceLines, "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.", ...candidateLines, diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index 16c42413df..4ba874cbe5 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -20,6 +20,8 @@ import { resolveAgentPrompt, builtinSeamPrompt, renderTriagePolicyPlaceholders, + resolveEffectiveSettings, + resolveEffectivePlannerHeartbeatPatrolEnabled, resolveTaskPlanningPrompt, resolveTaskSeamPrompt, resolvePersistAgentThinkingLog, @@ -1142,11 +1144,13 @@ export class TriageProcessor { const workflowFastPlanningPrompt = leanPlanning ? await resolveTaskSeamPrompt(this.store, task.id, "planning-fast").catch(() => 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. 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 || (leanPlanning ? (workflowFastPlanningPrompt || builtinSeamPrompt("planning-fast") || defaultTriagePrompt)