feat(forge): inject mode as cache-stable message, not cached prefix

- move mode overlay out of buildSystemPrompt (#85 source #1) — mode switch no longer breaks system cache
- buildModeMessage() returns banner + instructions, null for default
- prepareStep appends on first step + mode change only (lastEmittedMode); switch-to-default emits restrictions-lifted note
- pairs with execution-time deny gate (32f80a40): schemas full+cached, mode as append-only message
- works across TUI/headless/hearth + proxy+Claude; UI reads chat.forgeMode (unaffected)

Co-Authored-By: SoulForge <[email protected]>
This commit is contained in:
PouiiroandSoulForge committed 2026-05-31 16:29:10 +02:00
1 parent 555707bbef
commit bd06265579
5 files changed
+103 -9

No files matched your search

+33
View File
@@ -96,6 +96,7 @@ function buildForgePrepareStep(
buildSoulMapDiff(modelId?: string): string | null;
hasSoulMapDiff?(): boolean;
commitSoulMapDiff(): void;
buildModeMessage(): string | null;
buildSkillsBlock(): string | null;
buildMemoryRecallMessages(
lastUserMessage: string,
@@ -132,6 +133,13 @@ function buildForgePrepareStep(
}> = [];
let lastUserTurnCount = 0;
// Mode injection state: the active-mode reminder is appended as a cache-stable
// user message (NOT the system prompt) so mode switches don't break the system
// cache prefix (discussion #85, source #1). We emit it once on the first step
// and again only when the mode string actually changes. `undefined` = never
// emitted yet; `null` = last emitted state was default (no banner).
let lastEmittedMode: string | null | undefined = undefined;
// Commit-boundary nudge: recomputed fresh every step from message history (no closure state).
// Proxy instructions message — injected once as the first user message so the proxy
@@ -228,6 +236,31 @@ function buildForgePrepareStep(
soulMapDiff = contextManager.buildSoulMapDiff(modelId);
}
// ── Active-mode injection ────────────────────────────────────────
// Append the mode reminder as a cache-stable user message when the mode
// first appears or changes. Stable mode → no re-emit → byte-identical
// prefix. A switch back to default emits an explicit "default mode" note
// so the model sees prior restrictions lifted (the cached prefix never
// mentioned mode at all). Tools are always present in the schema; the
// execution-time gate in createForgeAgent enforces restrictions.
if (contextManager) {
const modeMsg = contextManager.buildModeMessage();
const modeKey = modeMsg ?? "default";
if (modeKey !== lastEmittedMode) {
const wasInitial = lastEmittedMode === undefined;
lastEmittedMode = modeKey;
if (modeMsg) {
hints.push(modeMsg);
} else if (!wasInitial) {
// Switched back to default from a restricted/named mode — tell the
// model restrictions are lifted (initial default needs no banner).
hints.push(
"Active mode: DEFAULT. Any prior mode restrictions are lifted — all tools are now available.",
);
}
}
}
// ── Memory recall injection ──────────────────────────────────────
// On every fresh user turn, ask the context manager for a recall pair
// (cached by lastUserMessage + edited-files snapshot + memory generation
+18
View File
@@ -465,6 +465,24 @@ export class ContextManager {
return Math.round((this.conversationTokens / window) * 100);
}
/**
* Build the active-mode reminder for injection as a cache-stable user message
* (NOT the system prompt). Keeping mode out of the cached prefix means
* switching Architect → Plan → Implement no longer invalidates the system
* cache breakpoint (discussion #85, cache-break source #1). prepareStep
* appends this only when the mode changes, so a stable mode keeps the prefix
* byte-identical. Returns null for `default` — the absence of a mode line IS
* the default mode.
*/
buildModeMessage(): string | null {
if (this.forgeMode === "default") return null;
const instructions = getModeInstructions(this.forgeMode, {
contextPercent: this.getContextPercent(),
});
const banner = `Active mode: ${this.forgeMode.toUpperCase()}.`;
return instructions ? `${banner}\n${instructions}` : banner;
}
setEditorIntegration(settings: EditorIntegration): void {
this.editorIntegration = settings;
}
+3 -6
View File
@@ -23,7 +23,6 @@ import {
GOOGLE_PROMPT,
OPENAI_PROMPT,
} from "./families/index.js";
import { getModeInstructions } from "./modes/index.js";
import { TOOL_GUIDANCE_NO_MAP, TOOL_GUIDANCE_WITH_MAP } from "./shared/index.js";
export type { ModelFamily } from "../llm/provider-options.js";
@@ -120,11 +119,9 @@ export function buildSystemPrompt(opts: PromptBuilderOptions): string {
// Project instructions (SOULFORGE.md, CLAUDE.md, etc.)
if (opts.projectInstructions) parts.push(opts.projectInstructions);
// Mode overlay
const modeInstructions = getModeInstructions(opts.forgeMode, {
contextPercent: opts.contextPercent,
});
if (modeInstructions) parts.push(`Mode: ${modeInstructions}`);
// Mode overlay is NOT baked into the cached system prompt — it's injected as
// a cache-stable user message in prepareStep (forge.ts) so switching modes
// doesn't invalidate the system cache breakpoint (discussion #85, source #1).
// 9. Skills reference (skills are injected as message pairs, not here)
parts.push(
+40
View File
@@ -85,3 +85,43 @@ describe("mode tool gating", () => {
expect(defaultEdit?.execute).not.toBe(architectEdit?.execute);
});
});
describe("buildModeMessage — cache-stable mode injection", () => {
test("default mode returns null (absence of banner = default)", () => {
const cm = new ContextManager(TMP);
cm.setForgeMode("default");
expect(cm.buildModeMessage()).toBeNull();
});
test("architect mode returns banner + instructions", () => {
const cm = new ContextManager(TMP);
cm.setForgeMode("architect");
const msg = cm.buildModeMessage();
expect(msg).toContain("Active mode: ARCHITECT.");
expect(msg).toContain("ARCHITECT MODE");
});
test("plan mode returns banner + plan instructions", () => {
const cm = new ContextManager(TMP);
cm.setForgeMode("plan");
const msg = cm.buildModeMessage();
expect(msg).toContain("Active mode: PLAN.");
expect(msg).toContain("PLAN MODE");
});
test("message is byte-stable for an unchanged mode", () => {
const cm = new ContextManager(TMP);
cm.setForgeMode("architect");
expect(cm.buildModeMessage()).toBe(cm.buildModeMessage());
});
test("message changes when mode changes", () => {
const cm = new ContextManager(TMP);
cm.setForgeMode("architect");
const a = cm.buildModeMessage();
cm.setForgeMode("challenge");
const b = cm.buildModeMessage();
expect(a).not.toBe(b);
expect(b).toContain("Active mode: CHALLENGE.");
});
});
+9 -3
View File
@@ -66,11 +66,17 @@ describe("buildSystemPrompt assembly", () => {
expect(withoutMap).not.toContain("<workflow>");
});
test("mode overlay only present for non-default modes", () => {
test("mode overlay is NEVER baked into the cached system prompt (cache stability)", () => {
// Mode instructions moved to a cache-stable user-message inject in forge.ts
// (discussion #85, source #1). The system prompt must be byte-identical
// across modes so switching them never invalidates the cache breakpoint.
const def = buildSystemPrompt(baseOpts({ forgeMode: "default" }));
const arch = buildSystemPrompt(baseOpts({ forgeMode: "architect" }));
expect(def).not.toContain("ARCHITECT MODE");
expect(arch).toContain("ARCHITECT MODE");
const plan = buildSystemPrompt(baseOpts({ forgeMode: "plan" }));
expect(arch).not.toContain("ARCHITECT MODE");
expect(plan).not.toContain("PLAN MODE");
expect(arch).toBe(def);
expect(plan).toBe(def);
});
test("routes by model family — claude vs openai produce different prompts", () => {