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