mirror of
https://github.com/proxysoul/Empryo.git
synced 2026-11-07 06:25:57 -10:00
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:
5 files changed
+103
-9
No files matched your search
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in new issue
Block a user