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