Skip to content

Commit 8c109d2

Browse files
committed
fix(compaction): skip extras on /clear and restore past 32 commits
1 parent c4a2f1e commit 8c109d2

2 files changed

Lines changed: 173 additions & 64 deletions

File tree

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

Lines changed: 147 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,90 @@ function stubChatAgentWiring(
259259
};
260260
}
261261

262+
type ManifestRecord = {
263+
strategy: string;
264+
version: string;
265+
parameters: Record<string, unknown>;
266+
reason: string;
267+
decisions: Record<string, unknown>;
268+
};
269+
270+
function pruningExtrasRecord(extraInstructions: string): ManifestRecord {
271+
return {
272+
strategy: "pruning-compactor",
273+
version: "1",
274+
parameters: { extraInstructions },
275+
reason: "compacted",
276+
decisions: {},
277+
};
278+
}
279+
280+
function laterManifestRecord(): ManifestRecord {
281+
return {
282+
strategy: "other",
283+
version: "1",
284+
parameters: {},
285+
reason: "later",
286+
decisions: {},
287+
};
288+
}
289+
290+
async function withAssembledDirector(
291+
input: {
292+
getRecords: () => readonly ManifestRecord[];
293+
directorHolder: { instance?: ChatDirector };
294+
wiring?: Partial<ChatAgentWiring>;
295+
},
296+
run: (buildAgent: () => Promise<unknown>) => Promise<void>,
297+
): Promise<void> {
298+
const fakeAgent = { close: async () => undefined } as unknown as Agent;
299+
const fakeStorage = {
300+
readBlob: async () => new Uint8Array(),
301+
readManifestHistory: async (limit: number) =>
302+
input.getRecords().slice(0, limit),
303+
} as unknown as ContextStore;
304+
305+
await withMockedModuleDuring(
306+
import.meta.resolve("./optimized-context-store.js"),
307+
(real: typeof import("./optimized-context-store.js")) => ({
308+
...real,
309+
createSessionStores: async () => ({
310+
storage: fakeStorage,
311+
audit: stubAuditStore(),
312+
}),
313+
}),
314+
async () => {
315+
await withMockedModuleDuring(
316+
import.meta.resolve("../agent/live-tool-dispatch.js"),
317+
(real: typeof import("../agent/live-tool-dispatch.js")) => ({
318+
...real,
319+
createAgentWithLiveToolDispatch: async (
320+
_def: unknown,
321+
env: { directors: DirectorRegistry },
322+
) => {
323+
env.directors.defaultFactory()({}, {} as never, {
324+
systemPrompt: "prompt",
325+
toolDefinitions: [],
326+
compactorNames: ["pruning-compactor"],
327+
});
328+
return fakeAgent;
329+
},
330+
}),
331+
async () => {
332+
const { assembleChatAgent } = await import("./assemble-runtime.js");
333+
const { buildAgent } = assembleChatAgent(
334+
stubChatAgentWiring({
335+
directorHolder: input.directorHolder,
336+
...input.wiring,
337+
}),
338+
);
339+
await run(buildAgent);
340+
},
341+
);
342+
},
343+
);
344+
}
345+
262346
describe("assembleChatAgent", () => {
263347
test("getWorkdir and getCompactor run at buildAgent time, not assemble time", async () => {
264348
const storeDirs: string[] = [];
@@ -395,69 +479,74 @@ describe("assembleChatAgent", () => {
395479
});
396480

397481
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-
}[] = [
482+
let records: ManifestRecord[] = [
483+
pruningExtrasRecord("keep the auth discussion"),
484+
];
485+
const directorHolder: { instance?: ChatDirector } = {};
486+
487+
await withAssembledDirector(
488+
{ getRecords: () => records, directorHolder },
489+
async (buildAgent) => {
490+
await buildAgent();
491+
expect(directorHolder.instance?.getCompactInstructions()).toBe(
492+
"keep the auth discussion",
493+
);
494+
// /model store-miss on the same session still inherits fromPrev.
495+
records = [];
496+
await buildAgent();
497+
expect(directorHolder.instance?.getCompactInstructions()).toBe(
498+
"keep the auth discussion",
499+
);
500+
},
501+
);
502+
});
503+
504+
test("/clear-like rebuild against an empty store does not inherit extraInstructions", async () => {
505+
let records: ManifestRecord[] = [
506+
pruningExtrasRecord("keep the auth discussion"),
507+
];
508+
let workdir = "/session-a";
509+
let sessionId = "session-a";
510+
const directorHolder: { instance?: ChatDirector } = {};
511+
512+
await withAssembledDirector(
405513
{
406-
strategy: "pruning-compactor",
407-
version: "1",
408-
parameters: { extraInstructions: "keep the auth discussion" },
409-
reason: "compacted",
410-
decisions: {},
514+
getRecords: () => records,
515+
directorHolder,
516+
wiring: {
517+
getWorkdir: () => workdir,
518+
getSessionId: () => sessionId,
519+
},
520+
},
521+
async (buildAgent) => {
522+
await buildAgent();
523+
expect(directorHolder.instance?.getCompactInstructions()).toBe(
524+
"keep the auth discussion",
525+
);
526+
records = [];
527+
workdir = "/session-b";
528+
sessionId = "session-b";
529+
await buildAgent();
530+
expect(
531+
directorHolder.instance?.getCompactInstructions(),
532+
).toBeUndefined();
411533
},
534+
);
535+
});
536+
537+
test("cold resume restores extraInstructions beyond 32 later cycles", async () => {
538+
const records: ManifestRecord[] = [
539+
...Array.from({ length: 32 }, () => laterManifestRecord()),
540+
pruningExtrasRecord("keep the auth discussion"),
412541
];
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;
418542
const directorHolder: { instance?: ChatDirector } = {};
419543

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-
},
544+
await withAssembledDirector(
545+
{ getRecords: () => records, directorHolder },
546+
async (buildAgent) => {
547+
await buildAgent();
548+
expect(directorHolder.instance?.getCompactInstructions()).toBe(
549+
"keep the auth discussion",
461550
);
462551
},
463552
);

‎src/session/assemble-runtime.ts‎

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -518,16 +518,23 @@ export interface AssembledChatAgent {
518518
buildAgent: () => Promise<Agent>;
519519
}
520520

521-
const STICKY_COMPACT_MANIFEST_LIMIT = 32;
521+
const STICKY_COMPACT_MANIFEST_PAGE = 32;
522522

523523
async function stickyExtraInstructionsFromStore(
524524
storage: ContextStore,
525525
): Promise<string | undefined> {
526526
if (typeof storage.readManifestHistory !== "function") return undefined;
527527
try {
528-
return stickyExtraInstructionsFromRecords(
529-
await storage.readManifestHistory(STICKY_COMPACT_MANIFEST_LIMIT),
530-
);
528+
let limit = STICKY_COMPACT_MANIFEST_PAGE;
529+
let previousLength = -1;
530+
for (;;) {
531+
const records = await storage.readManifestHistory(limit);
532+
const extra = stickyExtraInstructionsFromRecords(records);
533+
if (extra !== undefined) return extra;
534+
if (records.length === previousLength) return undefined;
535+
previousLength = records.length;
536+
limit *= 2;
537+
}
531538
} catch {
532539
return undefined;
533540
}
@@ -544,11 +551,18 @@ export function assembleChatAgent(wiring: ChatAgentWiring): AssembledChatAgent {
544551
// Newest-commit-first compact records from the store, refreshed each build
545552
// so resume and /model rebuilds restore sticky /compact instructions.
546553
let stickyFromStore: string | undefined;
554+
// /model rebuilds keep the same session store and may miss extras there;
555+
// /clear and /new mint a new workdir, so fromPrev must not follow.
556+
let inheritFromPrev = true;
557+
let lastWorkdir: string | undefined;
558+
let lastSessionId: string | undefined;
547559
const chatDirectorDef = defineDirector({
548560
id: `${ID_PREFIX}/chat`,
549561
configSchema: type({}),
550562
factory: (_cfg, _env, agentCtx) => {
551-
const fromPrev = directorHolder.instance?.getCompactInstructions();
563+
const fromPrev = inheritFromPrev
564+
? directorHolder.instance?.getCompactInstructions()
565+
: undefined;
552566
const d = createChatDirector(
553567
agentCtx.systemPrompt,
554568
wiring.computeAdvertised([...agentCtx.toolDefinitions]),
@@ -592,6 +606,12 @@ export function assembleChatAgent(wiring: ChatAgentWiring): AssembledChatAgent {
592606

593607
const buildAgent = async (): Promise<Agent> => {
594608
const workdir = wiring.getWorkdir();
609+
const sessionId = wiring.getSessionId();
610+
inheritFromPrev =
611+
lastWorkdir === undefined ||
612+
(lastWorkdir === workdir && lastSessionId === sessionId);
613+
lastWorkdir = workdir;
614+
lastSessionId = sessionId;
595615
const { storage, audit } = await createSessionStores(workdir);
596616
// Primary-only evidence archive. Workers never pass evidenceArchiveHolder, so
597617
// they keep plain storage and omit admission / authorize recording wraps.
@@ -671,7 +691,7 @@ export function assembleChatAgent(wiring: ChatAgentWiring): AssembledChatAgent {
671691
],
672692
},
673693
audit,
674-
sessionId: wiring.getSessionId(),
694+
sessionId,
675695
// Gate-backed reactor authorization: ask-tier calls suspend via the
676696
// vendored approval-suspend primitive instead of parking on a closure.
677697
// Finalize evidence admission after guards resolve; never scrub exec args.

0 commit comments

Comments
 (0)