fix(forge): mode inject latches on mode name + frozen turn-mode

- latch on mode NAME, not rendered text — plan mode no longer re-injects when context crosses the PLAN_LIGHT/PLAN_FULL 50% threshold mid-session
- banner uses the mode frozen at agent build (passed to prepareStep), matching the execution-time deny gate — no message/gate drift on mid-turn steering
- buildModeMessage(modeOverride?) renders the frozen mode instead of live state; mode change takes effect on next turn's rebuild
- tests: override-wins-over-live-state, override-default-null

Co-Authored-By: SoulForge <[email protected]>
This commit is contained in:
PouiiroandSoulForge committed 2026-05-31 17:04:37 +02:00
1 parent bd06265579
commit e5a216a38c
3 files changed
+43 -28

No files matched your search

+23 -15
View File
@@ -96,7 +96,7 @@ function buildForgePrepareStep(
buildSoulMapDiff(modelId?: string): string | null;
hasSoulMapDiff?(): boolean;
commitSoulMapDiff(): void;
buildModeMessage(): string | null;
buildModeMessage(modeOverride?: ForgeMode): string | null;
buildSkillsBlock(): string | null;
buildMemoryRecallMessages(
lastUserMessage: string,
@@ -110,6 +110,11 @@ function buildForgePrepareStep(
proxyInstructions?: string,
cacheOpts: ProviderOptions = EPHEMERAL_CACHE,
modelId?: string,
/** Mode frozen at agent-build time. The execution-time deny gate enforces
* this exact mode for the whole turn, so the injected mode banner must
* reflect it too — not live contextManager state, which can drift mid-turn
* on steering. A mode change takes effect on the next turn's agent rebuild. */
frozenMode: ForgeMode = "default",
) {
// Cache-stable inject tracking: the ToolLoopAgent discards prepareStep message
// modifications after each step (it rebuilds from initialMessages + responseMessages).
@@ -135,10 +140,11 @@ function buildForgePrepareStep(
// 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;
// cache prefix (discussion #85, source #1). Latch keys on the mode NAME so a
// context-threshold re-render of plan instructions doesn't re-inject. We emit
// once on the first step, then only when the name changes. `undefined` = never
// emitted yet.
let lastEmittedMode: ForgeMode | undefined;
// Commit-boundary nudge: recomputed fresh every step from message history (no closure state).
@@ -237,18 +243,20 @@ function buildForgePrepareStep(
}
// ── 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.
// Append the mode reminder as a cache-stable user message ONLY when the
// mode NAME actually changes. The latch keys on the mode name (stable),
// not the rendered text — plan mode renders different instructions as
// context grows (PLAN_LIGHT ≤50% → PLAN_FULL >50%), and keying on the
// text would re-inject mid-session on a threshold cross the user never
// triggered. A switch back to default emits an explicit "restrictions
// lifted" note. Tools are always in the schema; the execution-time gate
// in createForgeAgent enforces restrictions. Stable mode → no re-emit →
// byte-identical prefix.
if (contextManager) {
const modeMsg = contextManager.buildModeMessage();
const modeKey = modeMsg ?? "default";
if (modeKey !== lastEmittedMode) {
if (frozenMode !== lastEmittedMode) {
const wasInitial = lastEmittedMode === undefined;
lastEmittedMode = modeKey;
lastEmittedMode = frozenMode;
const modeMsg = contextManager.buildModeMessage(frozenMode);
if (modeMsg) {
hints.push(modeMsg);
} else if (!wasInitial) {
+5 -13
View File
@@ -465,21 +465,13 @@ 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, {
buildModeMessage(modeOverride?: ForgeMode): string | null {
const mode = modeOverride ?? this.forgeMode;
if (mode === "default") return null;
const instructions = getModeInstructions(mode, {
contextPercent: this.getContextPercent(),
});
const banner = `Active mode: ${this.forgeMode.toUpperCase()}.`;
const banner = `Active mode: ${mode.toUpperCase()}.`;
return instructions ? `${banner}\n${instructions}` : banner;
}
+15
View File
@@ -124,4 +124,19 @@ describe("buildModeMessage — cache-stable mode injection", () => {
expect(a).not.toBe(b);
expect(b).toContain("Active mode: CHALLENGE.");
});
test("override wins over live state (frozen turn-mode, no mid-turn drift)", () => {
// The deny gate enforces the mode frozen at agent build. The banner must
// reflect that same frozen mode even if live state changed mid-turn.
const cm = new ContextManager(TMP);
cm.setForgeMode("default"); // live state = default
const msg = cm.buildModeMessage("architect"); // frozen = architect
expect(msg).toContain("Active mode: ARCHITECT.");
});
test("override default returns null even when live state is restricted", () => {
const cm = new ContextManager(TMP);
cm.setForgeMode("architect");
expect(cm.buildModeMessage("default")).toBeNull();
});
});