feat(engine): add SystemPromptLayers type and builder for cross-session caching
This commit is contained in:
125
packages/engine/src/__tests__/prompt-layers.test.ts
Normal file
125
packages/engine/src/__tests__/prompt-layers.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
type SystemPromptLayers,
|
||||
buildPromptLayers,
|
||||
collapsePromptLayers,
|
||||
} from "../prompt-layers.js";
|
||||
|
||||
describe("buildPromptLayers", () => {
|
||||
it("separates base prompt into stable layer", () => {
|
||||
const layers = buildPromptLayers({
|
||||
basePrompt: "You are a reviewer.",
|
||||
});
|
||||
|
||||
expect(layers.stable).toBe("You are a reviewer.");
|
||||
expect(layers.dynamic).toBe("");
|
||||
});
|
||||
|
||||
it("puts agent instructions into dynamic layer", () => {
|
||||
const layers = buildPromptLayers({
|
||||
basePrompt: "You are a reviewer.",
|
||||
agentInstructions: "Always check for SQL injection.",
|
||||
});
|
||||
|
||||
expect(layers.stable).toBe("You are a reviewer.");
|
||||
expect(layers.dynamic).toContain("Always check for SQL injection.");
|
||||
});
|
||||
|
||||
it("puts memory section into dynamic layer", () => {
|
||||
const layers = buildPromptLayers({
|
||||
basePrompt: "You are a reviewer.",
|
||||
memorySection: "## Agent Memory\n\nRemember to check tests.",
|
||||
});
|
||||
|
||||
expect(layers.stable).toBe("You are a reviewer.");
|
||||
expect(layers.dynamic).toContain("Agent Memory");
|
||||
});
|
||||
|
||||
it("puts plugin contributions into dynamic layer", () => {
|
||||
const layers = buildPromptLayers({
|
||||
basePrompt: "You are a reviewer.",
|
||||
pluginContributions: "## Plugin: security\n\nScan for CVEs.",
|
||||
});
|
||||
|
||||
expect(layers.stable).toBe("You are a reviewer.");
|
||||
expect(layers.dynamic).toContain("security");
|
||||
});
|
||||
|
||||
it("puts performance feedback into dynamic layer", () => {
|
||||
const layers = buildPromptLayers({
|
||||
basePrompt: "You are a reviewer.",
|
||||
performanceFeedback: "## Performance Feedback\n\n- Average score: 8.5",
|
||||
});
|
||||
|
||||
expect(layers.stable).toBe("You are a reviewer.");
|
||||
expect(layers.dynamic).toContain("Performance Feedback");
|
||||
});
|
||||
|
||||
it("combines multiple dynamic sections with double newlines", () => {
|
||||
const layers = buildPromptLayers({
|
||||
basePrompt: "Base.",
|
||||
agentInstructions: "Instructions.",
|
||||
memorySection: "Memory.",
|
||||
pluginContributions: "Plugins.",
|
||||
});
|
||||
|
||||
expect(layers.dynamic).toBe(
|
||||
"## Custom Instructions\n\nInstructions.\n\nMemory.\n\nPlugins."
|
||||
);
|
||||
});
|
||||
|
||||
it("omits empty dynamic sections", () => {
|
||||
const layers = buildPromptLayers({
|
||||
basePrompt: "Base.",
|
||||
agentInstructions: "",
|
||||
memorySection: "",
|
||||
pluginContributions: "Plugins.",
|
||||
});
|
||||
|
||||
expect(layers.dynamic).toBe("Plugins.");
|
||||
expect(layers.dynamic).not.toContain("Custom Instructions");
|
||||
});
|
||||
|
||||
it("produces deterministic output for identical inputs", () => {
|
||||
const input = {
|
||||
basePrompt: "Base.",
|
||||
agentInstructions: "Inst.",
|
||||
memorySection: "Mem.",
|
||||
pluginContributions: "Plug.",
|
||||
performanceFeedback: "Perf.",
|
||||
};
|
||||
|
||||
const layers1 = buildPromptLayers(input);
|
||||
const layers2 = buildPromptLayers(input);
|
||||
|
||||
expect(layers1.stable).toBe(layers2.stable);
|
||||
expect(layers1.dynamic).toBe(layers2.dynamic);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collapsePromptLayers", () => {
|
||||
it("returns stable when dynamic is empty", () => {
|
||||
const result = collapsePromptLayers({ stable: "Base.", dynamic: "" });
|
||||
expect(result).toBe("Base.");
|
||||
});
|
||||
|
||||
it("joins stable and dynamic with double newline", () => {
|
||||
const result = collapsePromptLayers({
|
||||
stable: "Base.",
|
||||
dynamic: "Dynamic.",
|
||||
});
|
||||
expect(result).toBe("Base.\n\nDynamic.");
|
||||
});
|
||||
|
||||
it("matches legacy buildSystemPromptWithInstructions output", () => {
|
||||
const layers = buildPromptLayers({
|
||||
basePrompt: "You are a reviewer.",
|
||||
agentInstructions: "Check for bugs.",
|
||||
});
|
||||
const collapsed = collapsePromptLayers(layers);
|
||||
|
||||
expect(collapsed).toBe(
|
||||
"You are a reviewer.\n\n## Custom Instructions\n\nCheck for bugs."
|
||||
);
|
||||
});
|
||||
});
|
||||
81
packages/engine/src/prompt-layers.ts
Normal file
81
packages/engine/src/prompt-layers.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Structured system prompt layers for cross-session caching.
|
||||
*
|
||||
* The `stable` layer contains content that is identical across sessions of
|
||||
* the same role (base role prompt). The `dynamic` layer holds per-session
|
||||
* content (agent instructions, memory, performance feedback, plugins).
|
||||
*
|
||||
* When the stable layer is byte-identical across consecutive API calls,
|
||||
* Anthropic's prompt cache gives a 90% read discount. OpenAI caches
|
||||
* matching prefixes automatically at 50% discount.
|
||||
*/
|
||||
export interface SystemPromptLayers {
|
||||
/** Role-specific base prompt — identical across all sessions of this role. */
|
||||
stable: string;
|
||||
/** Per-session content: agent instructions, memory, feedback, plugins. */
|
||||
dynamic: string;
|
||||
}
|
||||
|
||||
export interface PromptLayerInput {
|
||||
/** The base role system prompt (e.g. REVIEWER_SYSTEM_PROMPT). */
|
||||
basePrompt: string;
|
||||
/** Resolved agent instructions (instructionsText + instructionsPath + soul). */
|
||||
agentInstructions?: string;
|
||||
/** Formatted memory section (agent memory + workspace memory). */
|
||||
memorySection?: string;
|
||||
/** Formatted plugin prompt contributions. */
|
||||
pluginContributions?: string;
|
||||
/** Formatted performance feedback section. */
|
||||
performanceFeedback?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build structured prompt layers from the components that currently get
|
||||
* concatenated into a single system prompt string.
|
||||
*
|
||||
* The stable layer is ONLY the base role prompt. Everything else goes into
|
||||
* the dynamic layer so that the stable prefix is byte-identical across
|
||||
* sessions of the same role, enabling cross-session prompt caching.
|
||||
*/
|
||||
export function buildPromptLayers(input: PromptLayerInput): SystemPromptLayers {
|
||||
const { basePrompt, agentInstructions, memorySection, pluginContributions, performanceFeedback } = input;
|
||||
|
||||
const dynamicParts: string[] = [];
|
||||
|
||||
const trimmedInstructions = agentInstructions?.trim() ?? "";
|
||||
if (trimmedInstructions) {
|
||||
dynamicParts.push(`## Custom Instructions\n\n${trimmedInstructions}`);
|
||||
}
|
||||
|
||||
const trimmedMemory = memorySection?.trim() ?? "";
|
||||
if (trimmedMemory) {
|
||||
dynamicParts.push(trimmedMemory);
|
||||
}
|
||||
|
||||
const trimmedPlugins = pluginContributions?.trim() ?? "";
|
||||
if (trimmedPlugins) {
|
||||
dynamicParts.push(trimmedPlugins);
|
||||
}
|
||||
|
||||
const trimmedFeedback = performanceFeedback?.trim() ?? "";
|
||||
if (trimmedFeedback) {
|
||||
dynamicParts.push(trimmedFeedback);
|
||||
}
|
||||
|
||||
return {
|
||||
stable: basePrompt,
|
||||
dynamic: dynamicParts.join("\n\n"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse layers back into a single string for backward compatibility.
|
||||
* Runtimes that don't support structured caching use this to get the same
|
||||
* concatenated prompt as before.
|
||||
*/
|
||||
export function collapsePromptLayers(layers: SystemPromptLayers): string {
|
||||
if (!layers.dynamic) {
|
||||
return layers.stable;
|
||||
}
|
||||
return `${layers.stable}\n\n${layers.dynamic}`;
|
||||
}
|
||||
Reference in New Issue
Block a user