FN-5653: inject active goal context into heartbeat and executor prompts

Align heartbeat and executor prompt assembly so both lanes receive the same active-goal context block.

- inject goalContext into heartbeat and executor buildPromptLayers inputs using buildGoalContextSection
- extend goal-context regression coverage with parity tests for heartbeat/executor injection and empty-goal behavior
- update executor test helpers for goal-store access and document the shared prompt-lane behavior in architecture/agents docs

Files changed:
 docs/agents.md                                     |  1 +
 docs/architecture.md                               |  1 +
 packages/engine/src/__tests__/executor-test-helpers.ts  |  3 +
 packages/engine/src/__tests__/goal-context-injection.test.ts   | 72 ++++++++++++++++++++++
 packages/engine/src/agent-heartbeat.ts             | 12 +++-
 packages/engine/src/executor.ts                    | 12 +++-
 packages/engine/src/prompt-layers.ts               |  9 ++-
 7 files changed, 107 insertions(+), 3 deletions(-)

Fusion-Task-Id: FN-5653
Fusion-Task-Lineage: d2670585-57f7-409d-bb11-cef3448267a6
This commit is contained in:
gsxdsm
2026-05-29 18:43:51 -07:00
parent a9d2064f66
commit ff575f8b06
7 changed files with 107 additions and 3 deletions

View File

@@ -279,6 +279,7 @@ Heartbeat sessions for durable agents resolve models with heartbeat-specific fal
When the runtime model is present and differs from execution-lane settings, heartbeat passes the execution-lane model as a fallback pair for session creation.
Task-scoped heartbeat runs for durable agents execute inside the task's git worktree (same as ephemeral task execution), while no-task heartbeat runs continue to execute from the project root.
Heartbeat and executor system prompts share the same active-goal context injector (`buildGoalContextSection`), so both lanes receive identical goal preambles when active goals exist.
If a heartbeat cannot create/run a session due to unavailable provider credentials or missing provider registration, Fusion records `resultJson.reason = "heartbeat_model_unavailable"` with actionable diagnostics in `resultJson.detail`/`stderrExcerpt`.

View File

@@ -436,6 +436,7 @@ Hybrid evaluator pipeline (FN-3389/FN-3391):
- `reviewer`
- `heartbeat`
- Integration points append the built plugin section to the role-specific system/task prompt only when contributions exist, preserving existing prompts when no plugins contribute.
- Executor and heartbeat system prompts also inject a shared `goalContext` dynamic layer via `buildGoalContextSection(...)`; when no active goals exist, no goal section is emitted.
### Agent Permissions

View File

@@ -364,6 +364,9 @@ export function createMockStore() {
listWorkflowSteps: vi.fn().mockResolvedValue([]),
setPluginWorkflowStepTemplates: vi.fn(),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
getGoalStore: vi.fn().mockReturnValue({
listGoals: vi.fn().mockReturnValue([]),
}),
getFusionDir: vi.fn().mockReturnValue("/tmp/test/.fusion"),
clearStaleExecutionStartBranchReferences: vi.fn().mockReturnValue([]),
};

View File

@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import type { Goal } from "@fusion/core";
import { buildGoalContextSection } from "../goal-context-injector.js";
import { buildPromptLayers, collapsePromptLayers } from "../prompt-layers.js";
function goal(id: string, title: string, createdAt: string): Goal {
return {
id,
title,
description: undefined,
status: "active",
createdAt,
updatedAt: createdAt,
};
}
function buildExecutorPrompt(activeGoals: Goal[]): { goalContext: string; prompt: string } {
const goalContext = buildGoalContextSection({ activeGoals }).text;
const layers = buildPromptLayers({
basePrompt: "EXECUTOR_BASE",
goalContext,
});
return { goalContext, prompt: collapsePromptLayers(layers) };
}
function buildHeartbeatPrompt(activeGoals: Goal[]): { goalContext: string; prompt: string } {
const goalContext = buildGoalContextSection({ activeGoals }).text;
const layers = buildPromptLayers({
basePrompt: "HEARTBEAT_BASE",
goalContext,
});
return { goalContext, prompt: collapsePromptLayers(layers) };
}
describe("goal context lane injection parity", () => {
it("injects byte-identical goal block across heartbeat and executor lanes", () => {
const activeGoals = [
goal("G-001", "Ship CLI", "2026-01-01T00:00:00.000Z"),
goal("G-002", "Harden engine", "2026-01-02T00:00:00.000Z"),
];
const expectedGoalBlock = buildGoalContextSection({ activeGoals }).text;
const executor = buildExecutorPrompt(activeGoals);
const heartbeat = buildHeartbeatPrompt(activeGoals);
expect(executor.goalContext).toBe(expectedGoalBlock);
expect(heartbeat.goalContext).toBe(expectedGoalBlock);
});
it("emits no goal header or blank-line artifact when active goals are empty", () => {
const executor = buildExecutorPrompt([]);
const heartbeat = buildHeartbeatPrompt([]);
expect(executor.goalContext).toBe("");
expect(heartbeat.goalContext).toBe("");
expect(executor.prompt).toBe("EXECUTOR_BASE");
expect(heartbeat.prompt).toBe("HEARTBEAT_BASE");
expect(executor.prompt).not.toContain("## Active Goals");
expect(heartbeat.prompt).not.toContain("## Active Goals");
});
it("uses shared formatter output without lane-local reformatting", () => {
const activeGoals = [goal("G-010", "Refine prompt caching", "2026-01-10T00:00:00.000Z")];
const helperOutput = buildGoalContextSection({ activeGoals }).text;
const executor = buildExecutorPrompt(activeGoals);
const heartbeat = buildHeartbeatPrompt(activeGoals);
expect(executor.goalContext).toEqual(helperOutput);
expect(heartbeat.goalContext).toEqual(helperOutput);
});
});

View File

@@ -17,7 +17,7 @@
* - onTerminated: Called when a heartbeat run is terminated
*/
import type { AgentStore, AgentHeartbeatRun, HeartbeatInvocationSource, AgentHeartbeatConfig, AgentBudgetStatus, Message, MessageStore, TaskStore, TaskDetail, AgentRole, Agent, InboxTask, RunMutationContext, Settings, AgentConfigRevision, ReflectionStore, ChatStore, ChatRoom, ChatRoomMessage, AgentMemoryInclusionMode } from "@fusion/core";
import type { AgentStore, AgentHeartbeatRun, HeartbeatInvocationSource, AgentHeartbeatConfig, AgentBudgetStatus, Message, MessageStore, TaskStore, TaskDetail, AgentRole, Agent, InboxTask, RunMutationContext, Settings, AgentConfigRevision, ReflectionStore, ChatStore, ChatRoom, ChatRoomMessage, AgentMemoryInclusionMode, Goal } from "@fusion/core";
import { AutoClaimSnapshotManager, type AutoClaimCandidate } from "./auto-claim-snapshot.js";
import { ApprovalRequestStore, buildExecutionMemoryInstructions, isEphemeralAgent, hasAgentIdentity, resolveEffectiveAgentPermissionPolicy, canAgentTakeImplementationTask, canAgentTakeImplementationTaskForExplicitRouting, resolvePersistAgentThinkingLog, resolveAgentMemoryInclusionMode } from "@fusion/core";
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
@@ -32,6 +32,7 @@ import {
} from "./agent-instructions.js";
import { resolveHeartbeatPromptTemplate, resolveHeartbeatScopeDisciplineMode, selectHeartbeatProcedure } from "./heartbeat-procedure-resolver.js";
import { buildPromptLayers, collapsePromptLayers } from "./prompt-layers.js";
import { buildGoalContextSection } from "./goal-context-injector.js";
import { createLogger, heartbeatLog, formatError } from "./logger.js";
import { acquireTaskWorktree } from "./worktree-acquisition.js";
import { createRunAuditor, type EngineRunContext } from "./run-audit.js";
@@ -2382,8 +2383,17 @@ export class HeartbeatMonitor {
heartbeatLog.log(`applied plugin prompt contributions for heartbeat surface`);
}
const goalStore = this.taskStore && typeof (this.taskStore as { getGoalStore?: unknown }).getGoalStore === "function"
? (this.taskStore as { getGoalStore: () => { listGoals?: (input: { status: "active" }) => Goal[] } }).getGoalStore()
: undefined;
const activeGoals = typeof goalStore?.listGoals === "function"
? goalStore.listGoals({ status: "active" })
: [];
const heartbeatGoalContext = buildGoalContextSection({ activeGoals }).text;
const heartbeatLayers = buildPromptLayers({
basePrompt: baseHeartbeatSystemPrompt,
goalContext: heartbeatGoalContext,
agentInstructions: [resolvedInstructionsText, memoryInstructions, selfImprovePrompt].filter((part) => part.trim()).join("\n\n"),
pluginContributions: heartbeatPluginContributions,
});

View File

@@ -8,7 +8,7 @@ const execAsync = promisify(exec);
import { delimiter, isAbsolute, join, relative, resolve as resolvePath } from "node:path";
import { existsSync, realpathSync } from "node:fs";
import { readFile, rm, writeFile } from "node:fs/promises";
import type { TaskStore, Task, TaskDetail, TaskTokenUsage, StepStatus, Settings, WorkflowStep, MissionStore, Slice, AgentState, AgentCapability, RunMutationContext, AgentHeartbeatConfig, Agent, AgentMemoryInclusionMode, ProjectSettings } from "@fusion/core";
import type { TaskStore, Task, TaskDetail, TaskTokenUsage, StepStatus, Settings, WorkflowStep, MissionStore, Slice, AgentState, AgentCapability, RunMutationContext, AgentHeartbeatConfig, Agent, AgentMemoryInclusionMode, ProjectSettings, Goal } from "@fusion/core";
import { RetryStormError, TaskDeletedError, serializeRetryStormError } from "@fusion/core";
import {
ApprovalRequestStore,
@@ -99,6 +99,7 @@ import {
buildPluginPromptSection,
} from "./agent-instructions.js";
import { buildPromptLayers, collapsePromptLayers } from "./prompt-layers.js";
import { buildGoalContextSection } from "./goal-context-injector.js";
import type { AgentReflectionService } from "./agent-reflection.js";
import { createRunAuditor, generateSyntheticRunId, type EngineRunContext, type RunAuditor } from "./run-audit.js";
import { AutoRecoveryDispatcher } from "./auto-recovery.js";
@@ -4147,8 +4148,17 @@ export class TaskExecutor {
executorLog.log(`${task.id}: applied plugin prompt contributions for executor-system surface`);
}
const goalStore = typeof (this.store as { getGoalStore?: unknown }).getGoalStore === "function"
? (this.store as { getGoalStore: () => { listGoals?: (input: { status: "active" }) => Goal[] } }).getGoalStore()
: undefined;
const activeGoals = typeof goalStore?.listGoals === "function"
? goalStore.listGoals({ status: "active" })
: [];
const executorGoalContext = buildGoalContextSection({ activeGoals }).text;
const executorLayers = buildPromptLayers({
basePrompt: getExecutorSystemPrompt(settings),
goalContext: executorGoalContext,
agentInstructions: executorInstructions,
pluginContributions: executorPluginContributions,
});

View File

@@ -23,6 +23,8 @@ export interface PromptLayerInput {
agentInstructions?: string;
/** Formatted memory section (agent memory + workspace memory). */
memorySection?: string;
/** Formatted active-goals context section. */
goalContext?: string;
/** Formatted plugin prompt contributions. */
pluginContributions?: string;
/** Formatted performance feedback section. */
@@ -38,7 +40,7 @@ export interface PromptLayerInput {
* sessions of the same role, enabling cross-session prompt caching.
*/
export function buildPromptLayers(input: PromptLayerInput): SystemPromptLayers {
const { basePrompt, agentInstructions, memorySection, pluginContributions, performanceFeedback } = input;
const { basePrompt, agentInstructions, memorySection, goalContext, pluginContributions, performanceFeedback } = input;
const dynamicParts: string[] = [];
@@ -51,6 +53,11 @@ export function buildPromptLayers(input: PromptLayerInput): SystemPromptLayers {
dynamicParts.push(trimmedMemory);
}
const trimmedGoalContext = goalContext?.trim() ?? "";
if (trimmedGoalContext) {
dynamicParts.push(trimmedGoalContext);
}
const trimmedInstructions = agentInstructions?.trim() ?? "";
if (trimmedInstructions) {
dynamicParts.push(`## Custom Instructions\n\n${trimmedInstructions}`);