FN-5651: add single-seam goal-context injector for system prompts

Introduce a dedicated injector seam that appends active goal context to system prompts.

- add goal-context injector module that builds deterministic system-prompt goal context from active goals
- export injector utilities through engine index for integration and reuse
- add focused tests covering formatting, filtering, ordering, and empty-context behavior

Files changed:
 .../src/__tests__/goal-context-injector.test.ts    | 188 +++++++++++++++++++++
 packages/engine/src/goal-context-injector.ts       | 142 ++++++++++++++++
 packages/engine/src/index.ts                       |   8 +
 3 files changed, 338 insertions(+)

Fusion-Task-Id: FN-5651

Fusion-Task-Lineage: 6fd3a8a0-f33a-48fd-b9f2-9a16c24c48c8
This commit is contained in:
gsxdsm
2026-05-29 07:11:00 -07:00
parent c89ad28e7b
commit 335c49748c
3 changed files with 338 additions and 0 deletions

View File

@@ -0,0 +1,188 @@
import { describe, expect, it, vi } from "vitest";
import type { Goal } from "@fusion/core";
import {
buildGoalContextSection,
DEFAULT_GOAL_INJECTION_CHAR_BUDGET,
} from "../goal-context-injector.js";
function goal(id: string, title: string, createdAt: string): Goal {
return {
id,
title,
description: undefined,
status: "active",
createdAt,
updatedAt: createdAt,
};
}
describe("buildGoalContextSection", () => {
it("returns empty payload for empty input", () => {
expect(buildGoalContextSection({ activeGoals: [] })).toEqual({
text: "",
emittedGoalIds: [],
truncated: null,
});
});
it("renders one goal with header and no truncation", () => {
const result = buildGoalContextSection({
activeGoals: [goal("G-001", "Ship MVP", "2026-01-01T00:00:00.000Z")],
});
expect(result.text).toBe("## Active Goals\n\n- G-001: Ship MVP");
expect(result.emittedGoalIds).toEqual(["G-001"]);
expect(result.truncated).toBeNull();
});
it("emits exactly five goals in createdAt ascending order", () => {
const result = buildGoalContextSection({
activeGoals: [
goal("G-001", "A", "2026-01-01T00:00:00.000Z"),
goal("G-002", "B", "2026-01-02T00:00:00.000Z"),
goal("G-003", "C", "2026-01-03T00:00:00.000Z"),
goal("G-004", "D", "2026-01-04T00:00:00.000Z"),
goal("G-005", "E", "2026-01-05T00:00:00.000Z"),
],
});
expect(result.emittedGoalIds).toEqual(["G-001", "G-002", "G-003", "G-004", "G-005"]);
expect(result.truncated).toBeNull();
});
it("caps at five and emits cap truncation event", () => {
const onTruncated = vi.fn();
const result = buildGoalContextSection({
activeGoals: [
goal("G-001", "A", "2026-01-01T00:00:00.000Z"),
goal("G-002", "B", "2026-01-02T00:00:00.000Z"),
goal("G-003", "C", "2026-01-03T00:00:00.000Z"),
goal("G-004", "D", "2026-01-04T00:00:00.000Z"),
goal("G-005", "E", "2026-01-05T00:00:00.000Z"),
goal("G-006", "F", "2026-01-06T00:00:00.000Z"),
goal("G-007", "G", "2026-01-07T00:00:00.000Z"),
],
onTruncated,
});
expect(result.emittedGoalIds).toEqual(["G-001", "G-002", "G-003", "G-004", "G-005"]);
expect(result.truncated?.reason).toBe("cap");
expect(result.truncated?.droppedGoalIds).toEqual(["G-006", "G-007"]);
expect(onTruncated).toHaveBeenCalledTimes(1);
});
it("never exceeds injector cap even when caller maxGoals is larger", () => {
const result = buildGoalContextSection({
activeGoals: [
goal("G-001", "A", "2026-01-01T00:00:00.000Z"),
goal("G-002", "B", "2026-01-02T00:00:00.000Z"),
goal("G-003", "C", "2026-01-03T00:00:00.000Z"),
goal("G-004", "D", "2026-01-04T00:00:00.000Z"),
goal("G-005", "E", "2026-01-05T00:00:00.000Z"),
goal("G-006", "F", "2026-01-06T00:00:00.000Z"),
],
maxGoals: 10,
});
expect(result.emittedGoalIds).toHaveLength(5);
});
it("uses id ascending tiebreaker when createdAt is identical", () => {
const result = buildGoalContextSection({
activeGoals: [
goal("G-200", "Later ID", "2026-01-01T00:00:00.000Z"),
goal("G-100", "Earlier ID", "2026-01-01T00:00:00.000Z"),
],
});
expect(result.emittedGoalIds).toEqual(["G-100", "G-200"]);
});
it("sanitizes title whitespace and embedded newlines", () => {
const result = buildGoalContextSection({
activeGoals: [goal("G-001", " Multi\n line\n title ", "2026-01-01T00:00:00.000Z")],
});
expect(result.text).toBe("## Active Goals\n\n- G-001: Multi line title");
});
it("drops newest goals first under budget pressure", () => {
const onTruncated = vi.fn();
const result = buildGoalContextSection({
activeGoals: [
goal("G-001", "Alpha", "2026-01-01T00:00:00.000Z"),
goal("G-002", "Bravo", "2026-01-02T00:00:00.000Z"),
goal("G-003", "Charlie", "2026-01-03T00:00:00.000Z"),
goal("G-004", "Delta", "2026-01-04T00:00:00.000Z"),
goal("G-005", "Echo", "2026-01-05T00:00:00.000Z"),
],
charBudget: 45,
onTruncated,
});
expect(result.truncated?.reason).toBe("budget");
expect(result.emittedGoalIds).toEqual(["G-001"]);
expect(result.truncated?.droppedGoalIds).toEqual(["G-005", "G-004", "G-003", "G-002"]);
expect(result.truncated?.producedChars).toBe(result.text.length);
expect(result.text.length <= 45 || result.emittedGoalIds.length === 1).toBe(true);
expect(onTruncated).toHaveBeenCalledTimes(1);
});
it("emits a single budget truncation event when cap and budget both apply", () => {
const result = buildGoalContextSection({
activeGoals: [
goal("G-001", "Alpha", "2026-01-01T00:00:00.000Z"),
goal("G-002", "Bravo", "2026-01-02T00:00:00.000Z"),
goal("G-003", "Charlie", "2026-01-03T00:00:00.000Z"),
goal("G-004", "Delta", "2026-01-04T00:00:00.000Z"),
goal("G-005", "Echo", "2026-01-05T00:00:00.000Z"),
goal("G-006", "Foxtrot", "2026-01-06T00:00:00.000Z"),
goal("G-007", "Golf", "2026-01-07T00:00:00.000Z"),
goal("G-008", "Hotel", "2026-01-08T00:00:00.000Z"),
],
charBudget: 45,
});
expect(result.truncated?.reason).toBe("budget");
expect(result.emittedGoalIds).toEqual(["G-001"]);
expect(result.truncated?.droppedGoalIds).toEqual([
"G-006",
"G-007",
"G-008",
"G-005",
"G-004",
"G-003",
"G-002",
]);
});
it("defensively sorts shuffled input", () => {
const shuffled = [
goal("G-003", "C", "2026-01-03T00:00:00.000Z"),
goal("G-001", "A", "2026-01-01T00:00:00.000Z"),
goal("G-002", "B", "2026-01-02T00:00:00.000Z"),
];
const sorted = [
goal("G-001", "A", "2026-01-01T00:00:00.000Z"),
goal("G-002", "B", "2026-01-02T00:00:00.000Z"),
goal("G-003", "C", "2026-01-03T00:00:00.000Z"),
];
expect(buildGoalContextSection({ activeGoals: shuffled })).toEqual(
buildGoalContextSection({ activeGoals: sorted }),
);
});
it("does not invoke onTruncated when no truncation occurs", () => {
const onTruncated = vi.fn();
buildGoalContextSection({
activeGoals: [goal("G-001", "Ship MVP", "2026-01-01T00:00:00.000Z")],
charBudget: DEFAULT_GOAL_INJECTION_CHAR_BUDGET,
onTruncated,
});
expect(onTruncated).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,142 @@
/**
* Slice 2 (Hybrid Anchoring) goal-context seam for system prompts.
*
* This module formats active goals into a compact, deterministic prompt section
* containing ID + title only. It intentionally omits descriptions/bodies to
* keep prompt footprint small and byte-stable for downstream session builders.
*
* Truncation is deterministic: goals are re-sorted oldest-first and newer goals
* are dropped first under cap/budget pressure so longest-standing strategy
* remains anchored. Wiring this seam into heartbeat/executor prompt assembly is
* handled separately by FN-5653.
*/
import * as core from "@fusion/core";
import type { Goal } from "@fusion/core";
/**
* Maximum number of goals this seam will ever inject.
*
* Mirrors the core active-goal limit and is asserted not to exceed it.
*/
export const MAX_INJECTED_GOALS = 5;
const resolvedActiveGoalLimit =
"ACTIVE_GOAL_LIMIT" in core && typeof core.ACTIVE_GOAL_LIMIT === "number"
? core.ACTIVE_GOAL_LIMIT
: MAX_INJECTED_GOALS;
if (MAX_INJECTED_GOALS > resolvedActiveGoalLimit) {
throw new Error(
`MAX_INJECTED_GOALS (${MAX_INJECTED_GOALS}) cannot exceed ACTIVE_GOAL_LIMIT (${resolvedActiveGoalLimit})`,
);
}
/**
* Default maximum character budget for the rendered Active Goals section.
*/
export const DEFAULT_GOAL_INJECTION_CHAR_BUDGET = 600;
/**
* Structured truncation event emitted when capping and/or budget pressure drops goals.
*/
export interface GoalInjectionTruncationEvent {
reason: "cap" | "budget";
requested: number;
emitted: number;
droppedGoalIds: string[];
charBudget: number;
producedChars: number;
}
/**
* Pure input contract for the goal-context seam.
*
* Callers fetch active goals (typically from GoalStore) and pass them in so
* this helper remains deterministic, side-effect-free, and trivially testable.
*/
export interface GoalInjectionInput {
activeGoals: Goal[];
maxGoals?: number;
charBudget?: number;
onTruncated?: (event: GoalInjectionTruncationEvent) => void;
}
/**
* Output payload for system prompt assembly.
*/
export interface GoalInjectionResult {
text: string;
emittedGoalIds: string[];
truncated: GoalInjectionTruncationEvent | null;
}
function normalizeTitle(title: string): string {
return title.replace(/\s*\n+\s*/g, " ").trim();
}
function renderGoalContext(goals: Goal[]): string {
const lines = goals.map((goal) => `- ${goal.id}: ${normalizeTitle(goal.title)}`);
return `## Active Goals\n\n${lines.join("\n")}`;
}
/**
* Build a deterministic Active Goals prompt section from caller-provided goals.
*
* The function defensively re-sorts by `createdAt` ascending then `id`
* ascending (tie-break) so output is stable even if callers pass shuffled input.
* It does not read GoalStore directly.
*/
export function buildGoalContextSection(input: GoalInjectionInput): GoalInjectionResult {
const { activeGoals, onTruncated } = input;
if (activeGoals.length === 0) {
return { text: "", emittedGoalIds: [], truncated: null };
}
const sortedGoals = [...activeGoals].sort((a, b) => {
const byCreated = a.createdAt.localeCompare(b.createdAt);
if (byCreated !== 0) {
return byCreated;
}
return a.id.localeCompare(b.id);
});
const resolvedMaxGoals = Math.min(input.maxGoals ?? MAX_INJECTED_GOALS, MAX_INJECTED_GOALS);
const charBudget = input.charBudget ?? DEFAULT_GOAL_INJECTION_CHAR_BUDGET;
const droppedGoalIds: string[] = [];
let selectedGoals = sortedGoals.slice(0, resolvedMaxGoals);
if (sortedGoals.length > resolvedMaxGoals) {
droppedGoalIds.push(...sortedGoals.slice(resolvedMaxGoals).map((goal) => goal.id));
}
let text = renderGoalContext(selectedGoals);
let usedBudgetDrop = false;
while (selectedGoals.length > 1 && text.length > charBudget) {
const dropped = selectedGoals[selectedGoals.length - 1];
selectedGoals = selectedGoals.slice(0, -1);
droppedGoalIds.push(dropped.id);
text = renderGoalContext(selectedGoals);
usedBudgetDrop = true;
}
let truncated: GoalInjectionTruncationEvent | null = null;
if (droppedGoalIds.length > 0) {
truncated = {
reason: usedBudgetDrop ? "budget" : "cap",
requested: activeGoals.length,
emitted: selectedGoals.length,
droppedGoalIds,
charBudget,
producedChars: text.length,
};
onTruncated?.(truncated);
}
return {
text,
emittedGoalIds: selectedGoals.map((goal) => goal.id),
truncated,
};
}

View File

@@ -174,6 +174,14 @@ export {
} from "./external-integrations/index.js";
export { fetchWebContent, assertSafeUrl, WebFetchError, type WebFetchOptions, type WebFetchResult, type WebFetchErrorCode } from "./web-fetch.js";
export { classifyTaskError, type ErrorClass, type TaskErrorClassification } from "./error-classifier.js";
export {
buildGoalContextSection,
DEFAULT_GOAL_INJECTION_CHAR_BUDGET,
MAX_INJECTED_GOALS,
type GoalInjectionInput,
type GoalInjectionResult,
type GoalInjectionTruncationEvent,
} from "./goal-context-injector.js";
export {
resolveWorktrunkBinary,
installWorktrunk,