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:
@@ -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`.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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([]),
|
||||
};
|
||||
|
||||
72
packages/engine/src/__tests__/goal-context-injection.test.ts
Normal file
72
packages/engine/src/__tests__/goal-context-injection.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user