Skip to content

Commit b254c62

Browse files
committed
fix(compaction): restore extraInstructions after director rebuild
The compact record stored operator instructions but a new governor on /model, interrupt, or resume never read them, so later auto-folds used the default structured prompt.
1 parent 6a5044f commit b254c62

10 files changed

Lines changed: 290 additions & 5 deletions

‎docs/ARCHITECTURE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ When a cycle's input tokens cross a threshold, the director compacts the inferen
204204

205205
The `ollama` table key is the bare provider segment; the harness stamps slash-form on `sourceId` (`ollama/default` / `ollama/<instance>`), with `provider: openai-compatible` (`buildOpenAISource`) and a bare model (`llama3` / `qwen3`). Idle-recompress disable matches via `isOllamaProviderId` on that `sourceId` (`src/provider/ollama.ts`), not by looking up slash-form as a table key.
206206

207-
- **Operator `/compact`** — A first-class slash command folds now without waiting for the occupancy governor. Optional trailing instructions go to the summarizer (empty uses the default structured fold) and are stored on the compact record so later auto-folds still see them. An in-flight turn uses the same pair-safe compact-then-continue hop as threshold compact; an idle compact shows the fold and does not start a new turn.
207+
- **Operator `/compact`** — A first-class slash command folds now without waiting for the occupancy governor. Optional trailing instructions go to the summarizer (empty uses the default structured fold) and are stored on the compact record so later auto-folds still see them. Director rebuild (`/model`, interrupt, resume) restores those instructions from the latest compact record's parameters. An in-flight turn uses the same pair-safe compact-then-continue hop as threshold compact; an idle compact shows the fold and does not start a new turn.
208208
- **Overflow recovery** — A `context_overflow` inference error would otherwise become a terminal error reply; the governor compacts and retries instead, bounded so a history the compactor cannot shrink does not loop forever. Overflow ignores hysteresis for the compact itself.
209209

210210
The TTL window is measured from the last `inference.done` (every inference rewrites the provider's prefix cache) with one fire per window per compact of any kind, and it never fires with a tool batch outstanding — stall pings can arrive mid-work. The consecutive-compact cap shared with the threshold path still bounds compact→infer→compact. Prompt-caching the summary call itself is an explicit non-goal: the fold carries no cache options.

‎src/agent/compaction.test.ts‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import type {
99
import {
1010
COMPACTION_CONTINUATION_EVENT,
1111
OPERATOR_COMPACT_REASON,
12+
compactFloorNoopNotice,
1213
createCompactionGovernor,
14+
stickyExtraInstructionsFromRecords,
1315
} from "./compaction.js";
1416
import {
1517
compactionResumeDeltaFor,
@@ -854,6 +856,82 @@ describe("compaction governor", () => {
854856
expect(governor.compactTurnCount).toBe(10);
855857
expect(governor.extraInstructions).toBe("keep the auth discussion");
856858
});
859+
860+
test("restoreExtraInstructions hydrates a new governor from a compact record", () => {
861+
const written = {
862+
strategy: "pruning-compactor",
863+
version: "1",
864+
parameters: { extraInstructions: "keep the auth discussion" },
865+
reason: "compacted",
866+
decisions: {},
867+
};
868+
expect(stickyExtraInstructionsFromRecords([written])).toBe(
869+
"keep the auth discussion",
870+
);
871+
872+
const rebuilt = createCompactionGovernor(undefined);
873+
expect(rebuilt.extraInstructions).toBeUndefined();
874+
rebuilt.restoreExtraInstructions(
875+
stickyExtraInstructionsFromRecords([written]),
876+
);
877+
expect(rebuilt.extraInstructions).toBe("keep the auth discussion");
878+
rebuilt.restoreExtraInstructions(undefined);
879+
expect(rebuilt.extraInstructions).toBe("keep the auth discussion");
880+
});
881+
882+
test("sticky extra instructions skip empty values and later non-pruning records", () => {
883+
expect(
884+
stickyExtraInstructionsFromRecords([
885+
{
886+
strategy: "other",
887+
parameters: { extraInstructions: "ignore me" },
888+
},
889+
{
890+
strategy: "pruning-compactor",
891+
parameters: { extraInstructions: " keep tests " },
892+
},
893+
{
894+
strategy: "pruning-compactor",
895+
parameters: { extraInstructions: "older" },
896+
},
897+
]),
898+
).toBe("keep tests");
899+
expect(
900+
stickyExtraInstructionsFromRecords([
901+
{
902+
strategy: "pruning-compactor",
903+
parameters: { extraInstructions: "" },
904+
},
905+
{
906+
strategy: "pruning-compactor",
907+
parameters: { extraInstructions: 12 },
908+
},
909+
]),
910+
).toBeUndefined();
911+
});
912+
913+
test("second /compact during the apply-to-meter window does not double-fold", () => {
914+
const governor = createCompactionGovernor(undefined);
915+
governor.noteInferenceDone(inferenceDone(1000), tenTurns);
916+
expect(governor.requestManual("keep the auth discussion")).toBe("kick");
917+
expect(
918+
governor.interceptIdleContinuation(emptyMessage(), capabilities),
919+
).not.toBeNull();
920+
921+
expect(governor.requestManual("also keep the billing notes")).toBe("armed");
922+
expect(governor.extraInstructions).toBe("also keep the billing notes");
923+
expect(
924+
governor.interceptIdleContinuation(emptyMessage(), capabilities),
925+
).toBeNull();
926+
expect(governor.resumeAfterCompact(emptyMessage())).toBe("meter");
927+
});
928+
929+
test("noop /compact below the floor tells the operator instructions were not saved", () => {
930+
expect(compactFloorNoopNotice("")).toBe("Nothing to compact yet.");
931+
expect(compactFloorNoopNotice("keep the auth discussion")).toBe(
932+
"Nothing to compact yet. Instructions were not saved.",
933+
);
934+
});
857935
});
858936

859937
describe("provider-aware idle recompress (CL-8745)", () => {

‎src/agent/compaction.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,38 @@ export type ManualCompactOptions = {
6868
turns?: readonly ConversationTurn[];
6969
};
7070

71+
type CompactRecordLike = {
72+
strategy?: string;
73+
parameters?: Record<string, unknown>;
74+
};
75+
76+
function extraInstructionsFromCompactRecord(
77+
record: CompactRecordLike,
78+
): string | undefined {
79+
if (record.strategy !== COMPACTOR_NAME) return undefined;
80+
const extra = record.parameters?.extraInstructions;
81+
if (typeof extra !== "string") return undefined;
82+
const trimmed = extra.trim();
83+
return trimmed.length > 0 ? trimmed : undefined;
84+
}
85+
86+
/** Newest-commit-first: first pruning-compactor extraInstructions wins. */
87+
export function stickyExtraInstructionsFromRecords(
88+
records: readonly CompactRecordLike[],
89+
): string | undefined {
90+
for (const record of records) {
91+
const extra = extraInstructionsFromCompactRecord(record);
92+
if (extra !== undefined) return extra;
93+
}
94+
return undefined;
95+
}
96+
97+
export function compactFloorNoopNotice(instructions: string): string {
98+
return instructions.trim().length > 0
99+
? "Nothing to compact yet. Instructions were not saved."
100+
: "Nothing to compact yet.";
101+
}
102+
71103
/** Build the continuation re-entry action for a compacted governor cycle. */
72104
export function compactionContinuationAction(
73105
capabilities: ReactorCapabilities,
@@ -499,6 +531,9 @@ export function createCompactionGovernor(
499531
if (turnCount <= MIN_TURNS_TO_COMPACT) return "noop";
500532
const trimmed = instructions.trim();
501533
if (trimmed.length > 0) extraInstructions = trimmed;
534+
// A compact is already in flight (apply-to-meter or post-compact infer).
535+
// Keep sticky instructions but do not kick a second fold.
536+
if (hasOutstandingContinuation()) return "armed";
502537
manualPending = true;
503538
if (options?.inFlight === true) return "armed";
504539
if (idlePending) return "armed";
@@ -510,6 +545,12 @@ export function createCompactionGovernor(
510545
return "kick";
511546
}
512547

548+
function restoreExtraInstructions(value: string | undefined): void {
549+
const trimmed = value?.trim();
550+
if (trimmed === undefined || trimmed.length === 0) return;
551+
extraInstructions = trimmed;
552+
}
553+
513554
return {
514555
get estimatedTokens(): number {
515556
return estimate.tokens;
@@ -527,6 +568,7 @@ export function createCompactionGovernor(
527568
return turnCount;
528569
},
529570
requestManual,
571+
restoreExtraInstructions,
530572
syncFromTurns,
531573
noteInferenceDone,
532574
notePostCompact,

‎src/agent/director.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -816,6 +816,10 @@ class ChatDirectorImpl extends DefaultDirector {
816816
return this.compaction.extraInstructions;
817817
}
818818

819+
restoreCompactInstructions(value: string | undefined): void {
820+
this.compaction.restoreExtraInstructions(value);
821+
}
822+
819823
getCompactTurnCount(): number {
820824
return this.compaction.compactTurnCount;
821825
}
@@ -1418,5 +1422,6 @@ export interface ChatDirector extends ReactorDirector {
14181422
options?: ManualCompactOptions,
14191423
): ManualCompactArming;
14201424
getCompactInstructions(): string | undefined;
1425+
restoreCompactInstructions(value: string | undefined): void;
14211426
getCompactTurnCount(): number;
14221427
}

‎src/context-compactor.test.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ import {
1515
type SessionMetadata,
1616
} from "./session/compactor.js";
1717
import { createModelSummarizer } from "./session/summarizer.js";
18+
import {
19+
createCompactionGovernor,
20+
stickyExtraInstructionsFromRecords,
21+
} from "./agent/compaction.js";
1822
import type {
1923
ConversationTurn,
2024
InferenceSource,
@@ -786,6 +790,44 @@ describe("createPruningCompactor — operator extra instructions", () => {
786790
"keep the auth discussion",
787791
);
788792
});
793+
794+
test("a rebuilt governor still passes stored extra instructions into the next fold", async () => {
795+
const turns: ConversationTurn[] = [
796+
makeTurn({ role: "assistant", content: [{ type: "text", text: "a" }] }),
797+
makeTurn({ role: "assistant", content: [{ type: "text", text: "b" }] }),
798+
makeTurn({ role: "user", content: [{ type: "text", text: "recent" }] }),
799+
];
800+
const written = await createPruningCompactor({
801+
keepRecentTurns: 1,
802+
summaryMaxChars: 500,
803+
summaryContext: () => ({ extraInstructions: "keep the auth discussion" }),
804+
summarize: async () => "summary text",
805+
}).apply(turns, mockStrategyCtx);
806+
807+
const rebuilt = createCompactionGovernor(undefined);
808+
rebuilt.restoreExtraInstructions(
809+
stickyExtraInstructionsFromRecords([written.record]),
810+
);
811+
812+
let captured: { extraInstructions?: string } | undefined;
813+
const next = await createPruningCompactor({
814+
keepRecentTurns: 1,
815+
summaryMaxChars: 500,
816+
summaryContext: () => {
817+
const extra = rebuilt.extraInstructions;
818+
return extra !== undefined ? { extraInstructions: extra } : undefined;
819+
},
820+
summarize: async (_folded, ctx) => {
821+
captured = ctx;
822+
return "later summary";
823+
},
824+
}).apply(turns, mockStrategyCtx);
825+
826+
expect(captured?.extraInstructions).toBe("keep the auth discussion");
827+
expect(next.record.parameters.extraInstructions).toBe(
828+
"keep the auth discussion",
829+
);
830+
});
789831
});
790832

791833
describe("createPruningCompactor — consolidated handoff (CL-7521)", () => {

‎src/director.test.ts‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ import {
66
CHAT_TOOLS_ACTIVATE_EVENT,
77
} from "./agent/director.js";
88
import type { WorkflowCoordinator } from "./workflows/coordinator.js";
9-
import { COMPACTION_CONTINUATION_EVENT } from "./agent/compaction.js";
9+
import {
10+
COMPACTION_CONTINUATION_EVENT,
11+
stickyExtraInstructionsFromRecords,
12+
} from "./agent/compaction.js";
1013
import { createAgentToolset } from "./agent/tools.js";
1114
import { createAdvertisedToolset } from "./session/assemble-runtime.js";
1215
import { createPermissionGate } from "./permission/gate.js";
@@ -1142,6 +1145,26 @@ describe("chatDirector compaction", () => {
11421145
);
11431146
expect(afterCompact.some((a) => a.type === "done")).toBe(false);
11441147
});
1148+
1149+
test("restoreCompactInstructions hydrates a rebuilt director from a compact record", () => {
1150+
const written = {
1151+
strategy: "pruning-compactor" as const,
1152+
version: "1",
1153+
parameters: { extraInstructions: "keep the auth discussion" },
1154+
reason: "compacted",
1155+
decisions: {},
1156+
};
1157+
const first = chatDirector("");
1158+
first.restoreCompactInstructions(
1159+
stickyExtraInstructionsFromRecords([written]),
1160+
);
1161+
expect(first.getCompactInstructions()).toBe("keep the auth discussion");
1162+
1163+
const rebuilt = chatDirector("");
1164+
expect(rebuilt.getCompactInstructions()).toBeUndefined();
1165+
rebuilt.restoreCompactInstructions(first.getCompactInstructions());
1166+
expect(rebuilt.getCompactInstructions()).toBe("keep the auth discussion");
1167+
});
11451168
});
11461169

11471170
describe("chatDirector LSP auto-activation", () => {

‎src/session/assemble-runtime.test.ts‎

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
22
import { mkdtemp } from "node:fs/promises";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
5-
import type { Agent } from "@intx/agent";
5+
import type { Agent, DirectorRegistry } from "@intx/agent";
66
import type {
77
AuditStore,
88
Compactor,
@@ -11,6 +11,7 @@ import type {
1111
} from "@intx/types/runtime";
1212

1313
import { withMockedModuleDuring } from "../../tests/helpers/mock-module.js";
14+
import type { ChatDirector } from "../agent/director.js";
1415
import {
1516
createAdvertisedToolset,
1617
loadSessionLocalSettings,
@@ -392,4 +393,73 @@ describe("assembleChatAgent", () => {
392393
expect(builtAgent).toBe(fakeAgent);
393394
expect(builtStorage).toBe(fakeStorage);
394395
});
396+
397+
test("rebuild restores extraInstructions from the latest compact record", async () => {
398+
let records: {
399+
strategy: string;
400+
version: string;
401+
parameters: Record<string, unknown>;
402+
reason: string;
403+
decisions: Record<string, unknown>;
404+
}[] = [
405+
{
406+
strategy: "pruning-compactor",
407+
version: "1",
408+
parameters: { extraInstructions: "keep the auth discussion" },
409+
reason: "compacted",
410+
decisions: {},
411+
},
412+
];
413+
const fakeStorage = {
414+
readBlob: async () => new Uint8Array(),
415+
readManifestHistory: async () => records,
416+
} as unknown as ContextStore;
417+
const fakeAgent = { close: async () => undefined } as unknown as Agent;
418+
const directorHolder: { instance?: ChatDirector } = {};
419+
420+
await withMockedModuleDuring(
421+
import.meta.resolve("./optimized-context-store.js"),
422+
(real: typeof import("./optimized-context-store.js")) => ({
423+
...real,
424+
createSessionStores: async () => ({
425+
storage: fakeStorage,
426+
audit: stubAuditStore(),
427+
}),
428+
}),
429+
async () => {
430+
await withMockedModuleDuring(
431+
import.meta.resolve("../agent/live-tool-dispatch.js"),
432+
(real: typeof import("../agent/live-tool-dispatch.js")) => ({
433+
...real,
434+
createAgentWithLiveToolDispatch: async (
435+
_def: unknown,
436+
env: { directors: DirectorRegistry },
437+
) => {
438+
env.directors.defaultFactory()({}, {} as never, {
439+
systemPrompt: "prompt",
440+
toolDefinitions: [],
441+
compactorNames: ["pruning-compactor"],
442+
});
443+
return fakeAgent;
444+
},
445+
}),
446+
async () => {
447+
const { assembleChatAgent } = await import("./assemble-runtime.js");
448+
const { buildAgent } = assembleChatAgent(
449+
stubChatAgentWiring({ directorHolder }),
450+
);
451+
await buildAgent();
452+
expect(directorHolder.instance?.getCompactInstructions()).toBe(
453+
"keep the auth discussion",
454+
);
455+
records = [];
456+
await buildAgent();
457+
expect(directorHolder.instance?.getCompactInstructions()).toBe(
458+
"keep the auth discussion",
459+
);
460+
},
461+
);
462+
},
463+
);
464+
});
395465
});

0 commit comments

Comments
 (0)