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:
7
.changeset/fn-7963-planner-heartbeat-patrol.md
Normal file
7
.changeset/fn-7963-planner-heartbeat-patrol.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 ?? "";
|
||||
}
|
||||
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, unknown> | boolean | null | undefined,
|
||||
): boolean {
|
||||
const value = typeof workflowEffective === "object" && workflowEffective !== null
|
||||
? workflowEffective[PLANNER_HEARTBEAT_PATROL_ENABLED_SETTING_ID]
|
||||
: workflowEffective;
|
||||
return value !== false;
|
||||
}
|
||||
|
||||
@@ -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<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 () => {
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -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<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 {
|
||||
shouldRunSelfImprove(agentId: string): Promise<boolean>;
|
||||
getSelfImprovePrompt(agentId: string): Promise<string>;
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user