diff --git a/e2e/compaction-read-paging.test.ts b/e2e/compaction-read-paging.test.ts index 8e8dbb556..a20e28cc9 100644 --- a/e2e/compaction-read-paging.test.ts +++ b/e2e/compaction-read-paging.test.ts @@ -41,20 +41,18 @@ function logLines(count: number): string { async function openFoldSession(): Promise { const session = await openE2ESession({ permissionGate: e2ePermissionGate(), - // A tiny tail budget keeps the fold honest: the default 7500-token + // A tiny tail budget keeps the fold honest: the default 2500-token // budget would absorb this small scenario into the live tail and the // compactor would correctly no-op. - compactionShape: { tailBudgetTokens: 10 }, - // Echo the summarized turns verbatim: the spine must exist, and this - // scenario asserts on the kept live bodies, not the summary text. - compactionCompletion: async (turns) => - turns - .flatMap((turn) => - turn.content.flatMap((block) => - block.type === "text" ? [block.text] : [], - ), - ) - .join("\n"), + // Budget fits the paged read windows in the live tail but not the bulky + // pad turns, so the fold still fires. + compactionShape: { tailBudgetTokens: 500 }, + // A short handoff, not a prompt echo: complete() receives the summarizer + // prompt turns, and a 4k echo gets truncated off the verify-repair tail + // so the fold aborts. This scenario asserts on kept live bodies, not the + // summary text. + compactionCompletion: async () => + "Goal: page through var/log/big.log. Next: keep reading remaining windows.", }); seedFile(session, "var/log/big.log", `${logLines(80)}\n`); return session; @@ -78,7 +76,7 @@ async function lastRequestBody(session: E2ESession): Promise { async function padTurns(session: E2ESession, count: number): Promise { for (let i = 0; i < count; i++) { session.harness.scenario.replyOnce("anthropic", { - text: `Acknowledged ${i}.`, + text: `Acknowledged ${i}. ${"pad".repeat(800)}`, headUsage: LOW_USAGE, }); await runUntilDone(session, `Pad turn ${i}.`); diff --git a/evals/compaction/fixtures.ts b/evals/compaction/fixtures.ts index 2ec9eeef8..0e6c2825b 100644 --- a/evals/compaction/fixtures.ts +++ b/evals/compaction/fixtures.ts @@ -11,7 +11,7 @@ export const BASELINE = { syntheticLowInput: 100, syntheticTriggerInput: 200000, outputTokens: 1, - keepRecentTurns: 6, + tailBudgetTokens: 2500, summaryMaxChars: 4000, wallTimeoutMs: 30000, } as const; diff --git a/src/agent/chat-event-subscribers.test.ts b/src/agent/chat-event-subscribers.test.ts index 96aa88213..0255f7db6 100644 --- a/src/agent/chat-event-subscribers.test.ts +++ b/src/agent/chat-event-subscribers.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { CHAT_TASKS_CHANGED_EVENT, CHAT_TOOLS_ACTIVATE_EVENT, + COMPACTION_FOLD_NONCONVERGED_EVENT, } from "./director.js"; import { handleChatDirectorEvent } from "./chat-event-subscribers.js"; import type { Task } from "./tasks.js"; @@ -119,4 +120,28 @@ describe("handleChatDirectorEvent", () => { expect(dispatched).toBe(false); expect(log.calls).toEqual([]); }); + + test("dispatches a fold-nonconverged notice", () => { + const seen: string[] = []; + const log = makeLog(); + const handled = handleChatDirectorEvent( + { + type: COMPACTION_FOLD_NONCONVERGED_EVENT, + data: { notice: "Context fold did not reduce occupancy." }, + }, + { + onTasksChanged: () => { + throw new Error("unexpected tasks-changed dispatch"); + }, + onToolsActivate: () => { + throw new Error("unexpected tools-activate dispatch"); + }, + onFoldNonConverged: (notice) => seen.push(notice), + }, + log.log, + ); + expect(handled).toBe(true); + expect(seen).toEqual(["Context fold did not reduce occupancy."]); + expect(log.calls).toEqual([]); + }); }); diff --git a/src/agent/chat-event-subscribers.ts b/src/agent/chat-event-subscribers.ts index ffc8a98d0..8361e91e2 100644 --- a/src/agent/chat-event-subscribers.ts +++ b/src/agent/chat-event-subscribers.ts @@ -10,14 +10,17 @@ import { type } from "arktype"; import { CHAT_TASKS_CHANGED_EVENT, CHAT_TOOLS_ACTIVATE_EVENT, + COMPACTION_FOLD_NONCONVERGED_EVENT, ChatTasksChangedDataSchema, ChatToolsActivateDataSchema, + CompactionFoldNonConvergedDataSchema, } from "./director.js"; import type { Task } from "./tasks.js"; export interface ChatDirectorEventHandlers { onTasksChanged: (tasks: Task[]) => void; onToolsActivate: (names: string[]) => void; + onFoldNonConverged?: (notice: string) => void; } export type ChatDirectorEventDebugLog = ( @@ -58,5 +61,17 @@ export function handleChatDirectorEvent( handlers.onToolsActivate(parsed.names); return true; } + if (event.type === COMPACTION_FOLD_NONCONVERGED_EVENT) { + const parsed = CompactionFoldNonConvergedDataSchema(event.data); + if (parsed instanceof type.errors) { + logDebug( + "chat fold-nonconverged event dropped invalid payload: {error}", + { error: parsed.summary }, + ); + return true; + } + handlers.onFoldNonConverged?.(parsed.notice); + return true; + } return false; } diff --git a/src/agent/compaction.test.ts b/src/agent/compaction.test.ts index 10554f1b2..4e1ae0944 100644 --- a/src/agent/compaction.test.ts +++ b/src/agent/compaction.test.ts @@ -10,14 +10,18 @@ import { COMPACTION_CONTINUATION_EVENT, OPERATOR_COMPACT_REASON, createCompactionGovernor, + foldNonConvergedNotice, stickyExtraInstructionsFromRecords, } from "./compaction.js"; +import { + COMPACTION_FOLD_NONCONVERGED_EVENT, + createChatDirector, +} from "./director.js"; import { compactionWideResumeDeltaFor, compactionThresholdFor, } from "../provider/context-window.js"; import { - COMPACTOR_KEEP_RECENT_TURNS, COMPACT_SPACER_TEXT, LEGACY_COMPACT_SPACER_TEXT, compactorNoOpFloor, @@ -149,7 +153,6 @@ const overThreshold = compactionThresholdFor("m") + 1; const wideDelta = compactionWideResumeDeltaFor("m"); const inferAction: ReactorAction[] = [{ type: "infer" }]; const tenTurns = turnsOfLength(10, 1); -const threeTurns = turnsOfLength(3, 1); describe("compaction governor", () => { test("stays inert below the threshold or with few turns", () => { @@ -159,7 +162,10 @@ describe("compaction governor", () => { governor.interceptActions(toolDone(), inferAction, capabilities), ).toBeNull(); - governor.noteInferenceDone(inferenceDone(overThreshold), threeTurns); + governor.noteInferenceDone( + inferenceDone(overThreshold), + turnsOfLength(1, 1), + ); expect( governor.interceptActions(toolDone(), inferAction, capabilities), ).toBeNull(); @@ -474,13 +480,12 @@ describe("compaction governor", () => { }); test("stays inert below the minimum-turn floor no matter how far over threshold", () => { - // Two turns is well under MIN_TURNS_TO_COMPACT. createPruningCompactor - // no-ops at the same floor (see session/compactor.ts), so arming here - // would spend a reactor cycle that cannot shrink anything. + // A single turn is the compactor no-op floor. Arming here would spend a + // reactor cycle that cannot shrink anything. const governor = createCompactionGovernor(() => undefined); governor.noteInferenceDone( inferenceDone(overThreshold * 10), - turnsOfLength(2, 1), + turnsOfLength(1, 1), ); expect( governor.interceptActions(toolDone(), inferAction, capabilities), @@ -514,9 +519,9 @@ describe("compaction governor", () => { test("never arms at the exact turn count createPruningCompactor no-ops on", () => { // createPruningCompactor's own no-op floor (session/compactor.ts) is - // compactorNoOpFloor(COMPACTOR_KEEP_RECENT_TURNS). Arming at or below it - // would spend a reactor cycle that is guaranteed to shrink nothing. - const floor = compactorNoOpFloor(COMPACTOR_KEEP_RECENT_TURNS); + // compactorNoOpFloor(). Arming at or below it would spend a reactor cycle + // that is guaranteed to shrink nothing. + const floor = compactorNoOpFloor(); const governor = createCompactionGovernor(() => undefined); governor.noteInferenceDone( inferenceDone(overThreshold), @@ -528,7 +533,7 @@ describe("compaction governor", () => { }); test("arms one turn past the floor createPruningCompactor no-ops on", () => { - const floor = compactorNoOpFloor(COMPACTOR_KEEP_RECENT_TURNS); + const floor = compactorNoOpFloor(); const governor = createCompactionGovernor(() => undefined); governor.noteInferenceDone( inferenceDone(overThreshold), @@ -708,7 +713,10 @@ describe("compaction governor", () => { test("manual compact no-ops below the compactor floor", () => { const governor = createCompactionGovernor(undefined); - governor.noteInferenceDone(inferenceDone(overThreshold), threeTurns); + governor.noteInferenceDone( + inferenceDone(overThreshold), + turnsOfLength(1, 1), + ); expect(governor.requestManual("focus on tests")).toBe("noop"); expect(governor.extraInstructions).toBeUndefined(); expect( @@ -828,7 +836,7 @@ describe("post-compact above-threshold latch (CL-9006)", () => { ).toBeNull(); }); - test("a wide resume gap while still over threshold re-arms", () => { + test("a still-over fold reports non-convergence instead of re-arming on a wide gap", () => { const governor = createCompactionGovernor(() => undefined); governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns); expect( @@ -836,17 +844,112 @@ describe("post-compact above-threshold latch (CL-9006)", () => { ).not.toBeNull(); governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns); + expect(governor.foldNonConverged).toBe(true); governor.noteInferenceDone( inferenceDone(overThreshold + wideDelta), tenTurns, ); - const actions = governor.interceptActions( - toolDone(), - inferAction, - capabilities, + expect( + governor.interceptActions(toolDone(), inferAction, capabilities), + ).toBeNull(); + expect(governor.foldNonConverged).toBe(true); + }); + + test("foldNonConvergedNotice names the still-over fold for the operator", () => { + expect(foldNonConvergedNotice()).toMatch(/did not reduce occupancy/i); + expect(foldNonConvergedNotice()).toMatch(/paused/i); + }); + + test("ChatDirector emits the non-convergence notice once after a still-over fold", async () => { + const director = createChatDirector("sys", [], {}); + const caps: ReactorCapabilities = { + infer: () => ({ type: "infer" }), + executeTools: () => ({ type: "execute_tools", calls: [] }), + suspend: (gate) => ({ type: "suspend", gate }), + fork: (mode, forkId) => ({ type: "fork", mode, forkId }), + emit: (eventType, data) => ({ type: "emit", eventType, data }), + reply: (content: string) => ({ type: "reply", content }), + checkpoint: (message = "") => ({ type: "checkpoint", message }), + compact: (compactor, reason) => ({ type: "compact", compactor, reason }), + wait: () => ({ type: "wait" }), + done: () => ({ type: "done" }), + }; + const manyTurns = { + turns: Array.from({ length: 8 }, () => ({ + role: "assistant" as const, + content: [], + model: "test-model", + timestamp: 0, + })), + activeForks: [], + pendingOperations: [], + activeGates: [], + tokenUsage: usage(overThreshold), + lastCycleUsage: null, + lastCycleSource: null, + sessionId: "test-session", + }; + const done = (input: number): ReactorInboundEvent => + ({ + type: "inference.done", + turn: { + role: "assistant", + content: [ + { + type: "tool_call", + id: "call-1", + name: "read_file", + arguments: { path: "x.ts" }, + }, + ], + model: "test-model", + timestamp: 0, + }, + usage: usage(input), + source: { + sourceId: "test", + provider: "openai", + model: "test-model", + }, + }) as ReactorInboundEvent; + const toolDoneEvent = { + type: "tool.done", + result: { callId: "call-1", content: "ok", isError: false }, + } as ReactorInboundEvent; + + await director.decide(done(overThreshold), manyTurns as never, caps); + await director.decide(toolDoneEvent, manyTurns as never, caps); + const post = await director.decide( + done(overThreshold), + manyTurns as never, + caps, ); - expect(actions).not.toBeNull(); - expect(actions?.some((a) => a.type === "compact")).toBe(true); + const arr = Array.isArray(post) ? post : [post]; + const notices = arr.filter( + (a) => + a.type === "emit" && + "eventType" in a && + a.eventType === COMPACTION_FOLD_NONCONVERGED_EVENT, + ); + expect(notices).toHaveLength(1); + expect(notices[0]).toMatchObject({ + data: { notice: foldNonConvergedNotice() }, + }); + + const again = await director.decide( + done(overThreshold + 1), + manyTurns as never, + caps, + ); + const againArr = Array.isArray(again) ? again : [again]; + expect( + againArr.filter( + (a) => + a.type === "emit" && + "eventType" in a && + a.eventType === COMPACTION_FOLD_NONCONVERGED_EVENT, + ), + ).toHaveLength(0); }); test("the consecutive-compact cap holds across tool-call occupancy", () => { @@ -856,21 +959,10 @@ describe("post-compact above-threshold latch (CL-9006)", () => { governor.interceptActions(toolDone(), inferAction, capabilities), ).not.toBeNull(); + // Post-compact measurement still over: the fold is non-converged and the + // cap is spent, even past a wide gap and tool-call occupancy. governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns); - governor.noteInferenceDone( - inferenceDone(overThreshold + wideDelta), - tenTurns, - ); - expect( - governor.interceptActions(toolDone(), inferAction, capabilities), - ).not.toBeNull(); - - // Post-compact measurement still over: tool-call occupancy must not reset - // the cap, even past a wide gap. - governor.noteInferenceDone( - inferenceDone(overThreshold + wideDelta), - tenTurns, - ); + expect(governor.foldNonConverged).toBe(true); governor.noteInferenceDone( inferenceDoneWithTools(overThreshold + 2 * wideDelta), tenTurns, @@ -878,7 +970,6 @@ describe("post-compact above-threshold latch (CL-9006)", () => { expect( governor.interceptActions(toolDone(), inferAction, capabilities), ).toBeNull(); - // The idle path shares the same cap. governor.noteIdleTurn( inferenceDoneWithTools(overThreshold + 2 * wideDelta), [{ type: "reply", content: "done" }], @@ -942,19 +1033,17 @@ describe("post-compact above-threshold latch (CL-9006)", () => { expect( governor.interceptActions(toolDone(), inferAction, capabilities), ).toBeNull(); + expect(governor.foldNonConverged).toBe(true); - // Wide gap re-arms identically to the automatic path. + // Wide gap does not silently re-arm the automatic path after a still-over + // operator fold; the fold already reported non-convergence. governor.noteInferenceDone( inferenceDone(overThreshold + wideDelta), tenTurns, ); - const actions = governor.interceptActions( - toolDone(), - inferAction, - capabilities, - ); - expect(actions).not.toBeNull(); - expect(actions?.some((a) => a.type === "compact")).toBe(true); + expect( + governor.interceptActions(toolDone(), inferAction, capabilities), + ).toBeNull(); }); }); @@ -1012,7 +1101,7 @@ describe("handoff arming (/handoff)", () => { test("requestHandoff noops at or below the fold floor and arms nothing", () => { const governor = createCompactionGovernor(undefined); - governor.syncFromTurns(threeTurns); + governor.syncFromTurns(turnsOfLength(1, 1)); expect(governor.requestHandoff("now do the UI audit")).toBe("noop"); expect(governor.extraInstructions).toBeUndefined(); expect( @@ -1226,7 +1315,9 @@ describe("handoff arming (/handoff)", () => { { title: "noop then cancelManual does not wipe extras from a prior fold", fire: "idle", - midRequests: [{ turns: threeTurns, text: "wipe this", result: "noop" }], + midRequests: [ + { turns: turnsOfLength(1, 1), text: "wipe this", result: "noop" }, + ], }, { title: @@ -1318,7 +1409,7 @@ describe("handoff arming (/handoff)", () => { expect( governor.interceptIdleContinuation(emptyMessage(), capabilities), ).not.toBeNull(); - governor.syncFromTurns(threeTurns); + governor.syncFromTurns(turnsOfLength(1, 1)); expect(governor.requestHandoff("wipe this")).toBe("noop"); governor.cancelManual(); expect( diff --git a/src/agent/compaction.ts b/src/agent/compaction.ts index d3951ac5f..9d5785bf4 100644 --- a/src/agent/compaction.ts +++ b/src/agent/compaction.ts @@ -13,7 +13,6 @@ import { isAtOrUnderCompactThreshold, } from "../provider/context-window.js"; import { - COMPACTOR_KEEP_RECENT_TURNS, assistantTextIsCompactSpacerEcho, compactorNoOpFloor, isCompactSpacerEchoTurn, @@ -26,11 +25,10 @@ import { onTurnBoundary } from "./reactor-events.js"; const COMPACTOR_NAME = "pruning-compactor"; // The exact turn count `createPruningCompactor` (session/compactor.ts) is -// guaranteed to no-op on. Derived from the same keepRecentTurns both real -// registrations (session, sub-agent) use, so this floor cannot silently -// drift from what the compactor will actually do — arming at or below it -// would spend a reactor cycle that shrinks nothing. -const MIN_TURNS_TO_COMPACT = compactorNoOpFloor(COMPACTOR_KEEP_RECENT_TURNS); +// guaranteed to no-op on. Derived from the same helper so this floor cannot +// silently drift from what the compactor will actually do — arming at or +// below it would spend a reactor cycle that shrinks nothing. +const MIN_TURNS_TO_COMPACT = compactorNoOpFloor(); const MAX_OVERFLOW_RECOVERIES = 2; // Last-ditch bound on compact→infer→compact when the post-compact infer never // gets under the high watermark. Counts consecutive threshold compacts with no @@ -105,6 +103,11 @@ export function compactFloorNoopNotice(instructions: string): string { : "Nothing to compact yet."; } +/** Operator-facing notice after a fold whose post-compact occupancy is still over threshold. */ +export function foldNonConvergedNotice(): string { + return "Context fold did not reduce occupancy below the compact threshold. Further automatic folds are paused until usage drops."; +} + /** Run-record `provider:model` (or slash form) as a LastCycleSource. */ export function lastCycleSourceFromRunModel( model: string | undefined, @@ -173,6 +176,11 @@ export function createCompactionGovernor( // operator, and overflow alike); only fold evidence moves past it. let tokensAtLastCompact: number | undefined; let awaitingPostCompactMeasurement = false; + // Set when a fold's post-compact measurement is still at or above the + // threshold. The CL-9006 latch still holds (small growth does not re-arm); + // the consecutive cap is spent immediately so a still-over fold reports + // non-convergence instead of silently saw-toothing on the wide-gap re-arm. + let foldNonConverged = false; // Running local estimate of the turns we send, plus the fixed system-prompt // and tool-schema overhead every request carries. Providers that omit usage @@ -266,6 +274,10 @@ export function createCompactionGovernor( if (awaitingPostCompactMeasurement) { tokensAtLastCompact = contextTokens; awaitingPostCompactMeasurement = false; + if (!isAtOrUnderCompactThreshold(contextTokens, lastModel)) { + foldNonConverged = true; + consecutiveThresholdCompacts = MAX_CONSECUTIVE_THRESHOLD_COMPACTS; + } } // Fold evidence: usage back at or under the threshold clears the latch // and restores both rails (consecutive threshold compacts, overflow @@ -275,6 +287,7 @@ export function createCompactionGovernor( tokensAtLastCompact = undefined; consecutiveThresholdCompacts = 0; overflowRecoveries = 0; + foldNonConverged = false; } // Assign, don't OR: an under-threshold follow-up must disarm a sticky // pending left from an earlier over-threshold turn (e.g. after the @@ -579,6 +592,9 @@ export function createCompactionGovernor( get compactTurnCount(): number { return turnCount; }, + get foldNonConverged(): boolean { + return foldNonConverged; + }, requestManual, restoreExtraInstructions, restoreCacheWrite, diff --git a/src/agent/director.test.ts b/src/agent/director.test.ts index b3de1d028..042a9efc3 100644 --- a/src/agent/director.test.ts +++ b/src/agent/director.test.ts @@ -21,7 +21,6 @@ import { createAgentToolset } from "./tools.js"; import { createAdvertisedToolset } from "../session/assemble-runtime.js"; import { createPermissionGate } from "../permission/gate.js"; import { - COMPACTOR_KEEP_RECENT_TURNS, COMPACT_SPACER_TEXT, LEGACY_COMPACT_SPACER_TEXT, compactorNoOpFloor, @@ -802,14 +801,11 @@ function makeToolErrorEvent(callId: string, content: string) { // One turn past createPruningCompactor's own no-op floor (session/compactor.ts), // so the arming check finds a history actually worth compacting. const longState = { - turns: Array.from( - { length: compactorNoOpFloor(COMPACTOR_KEEP_RECENT_TURNS) + 1 }, - () => ({ - role: "user", - content: [], - timestamp: 0, - }), - ), + turns: Array.from({ length: compactorNoOpFloor() + 1 }, () => ({ + role: "user", + content: [], + timestamp: 0, + })), } as unknown as ReactorState; function messageReceived(content: string): ReactorInboundEvent { @@ -1594,14 +1590,11 @@ describe("chatDirector compaction", () => { test("idle empty compact makes the post-compact estimate authoritative without inferring", async () => { const director = createChatDirector("", [], {}); - const largeTurns = Array.from( - { length: compactorNoOpFloor(COMPACTOR_KEEP_RECENT_TURNS) + 1 }, - (_, i) => ({ - role: i % 2 === 0 ? "user" : "assistant", - content: [{ type: "text", text: "x".repeat(200) }], - timestamp: i, - }), - ); + const largeTurns = Array.from({ length: 8 }, (_, i) => ({ + role: i % 2 === 0 ? "user" : "assistant", + content: [{ type: "text", text: "x".repeat(200) }], + timestamp: i, + })); const longTurnsState = { turns: largeTurns } as unknown as ReactorState; await director.decide( diff --git a/src/agent/director.ts b/src/agent/director.ts index d59872cdb..429f05c52 100644 --- a/src/agent/director.ts +++ b/src/agent/director.ts @@ -20,6 +20,7 @@ import type { WorkflowCoordinator } from "../workflows/coordinator.js"; import { compactionContinuationAction, createCompactionGovernor, + foldNonConvergedNotice, type CompactionGovernor, type HandoffArming, type ManualCompactArming, @@ -457,6 +458,8 @@ function applyManageTasksToolCall( // (inference., tool., reactor., fork.). export const CHAT_TASKS_CHANGED_EVENT = "custom.chat.tasks.changed"; export const CHAT_TOOLS_ACTIVATE_EVENT = "custom.chat.tools.activate"; +export const COMPACTION_FOLD_NONCONVERGED_EVENT = + "custom.compaction.fold_nonconverged"; export const CREDENTIAL_RECOVERY_INTERCHANGE_TYPE = "system.credential.refresh" as const; export const ChatTasksChangedDataSchema = type({ @@ -465,6 +468,9 @@ export const ChatTasksChangedDataSchema = type({ export const ChatToolsActivateDataSchema = type({ names: "string[]", }); +export const CompactionFoldNonConvergedDataSchema = type({ + notice: "string", +}); export interface ChatDirectorOptions { // CL-7919: task-boundary classification and workflow coordination are not @@ -1273,7 +1279,15 @@ class ChatDirectorImpl extends DefaultDirector { const cycled = state.lastCycleSource?.sourceId; if (cycled !== undefined && cycled !== "") this.currentSourceId = cycled; if (onTurnBoundary(event)) { + const wasNonConverged = this.compaction.foldNonConverged; this.compaction.noteInferenceDone(event, turns); + if (this.compaction.foldNonConverged && !wasNonConverged) { + this.pendingEmits.push( + capabilities.emit(COMPACTION_FOLD_NONCONVERGED_EVENT, { + notice: foldNonConvergedNotice(), + }), + ); + } } if ( diff --git a/src/context-compactor.test.ts b/src/context-compactor.test.ts index efc0855bd..fe14afe92 100644 --- a/src/context-compactor.test.ts +++ b/src/context-compactor.test.ts @@ -9,7 +9,11 @@ import { LEGACY_COMPACT_SPACER_TEXT, HARNESS_COMPACT_SPACER_MODEL, isHarnessCompactSpacer, + DEFAULT_TAIL_COMPACTION_SHAPE, } from "./session/compactor.js"; +import { HANDOFF_LATEST_KEY } from "./session/compaction-handoff.js"; +import { compactionThresholdFor } from "./provider/context-window.js"; +import { estimateContextTokens } from "./agent/context-estimate.js"; import { createModelSummarizer } from "./session/summarizer.js"; import { createCompactionGovernor, @@ -47,15 +51,21 @@ function allText(turns: ConversationTurn[]): string { .join("\n"); } +function handoffFile( + blobs: { key: string; bytes: Uint8Array }[] | undefined, +): string { + const blob = blobs?.find((b) => b.key === HANDOFF_LATEST_KEY); + return blob === undefined ? "" : new TextDecoder().decode(blob.bytes); +} + function hasConsecutiveSameRole(turns: ConversationTurn[]): boolean { return turns.some((t, i) => i > 0 && defined(turns[i - 1]).role === t.role); } type CompactorConfig = Parameters[0]; -// CL-9007: every test pins a tiny tail budget so the fold covers the same -// older region the old keepRecentTurns cut folded (tailBudgetTokens: 1 keeps -// only the mandatory floor live). Pass tailBudgetTokens instead of a full +// CL-9489: every test pins a tiny tail budget so the fold covers older +// turns the token cap leaves out. Pass tailBudgetTokens instead of a full // compactionShape. function smallCompactor( cfg: Omit, "compactionShape"> & { @@ -72,7 +82,6 @@ function smallCompactor( describe("createPruningCompactor", () => { test("returns turns unchanged when under the keep threshold", async () => { const compactor = smallCompactor({ - keepRecentTurns: 5, summaryMaxChars: 500, }); const turns: ConversationTurn[] = [ @@ -89,12 +98,10 @@ describe("createPruningCompactor", () => { // from this function so it never arms a compaction guaranteed to no-op. // Anyone changing apply()'s no-op condition without updating // compactorNoOpFloor accordingly breaks that guarantee silently. - const keepRecentTurns = 3; const compactor = smallCompactor({ - keepRecentTurns, summaryMaxChars: 500, }); - const floor = compactorNoOpFloor(keepRecentTurns); + const floor = compactorNoOpFloor(); // Bodies exceed the pinned tail budget, so past-floor always has a folded // region while at-floor still no-ops on the count check alone. @@ -105,7 +112,9 @@ describe("createPruningCompactor", () => { content: [{ type: "text", text: body(i) }], }), ); - const pastFloor = Array.from({ length: floor + 1 }, (_, i) => + // Past the count floor AND past the tail budget: initiating + middle + tail + // so there is a summarized region, not a keep-set no-op. + const pastFloor = Array.from({ length: floor + 3 }, (_, i) => makeTurn({ role: i % 2 === 0 ? "user" : "assistant", content: [{ type: "text", text: body(i) }], @@ -122,7 +131,6 @@ describe("createPruningCompactor", () => { test("compacts old turns and preserves recent ones", async () => { const compactor = smallCompactor({ - keepRecentTurns: 2, summaryMaxChars: 500, }); const turns: ConversationTurn[] = [ @@ -169,7 +177,6 @@ describe("createPruningCompactor — initiating task preservation", () => { test("emits the compaction summary as a user turn, never system", async () => { const compactor = smallCompactor({ tailBudgetTokens: 1, - keepRecentTurns: 1, summaryMaxChars: 500, }); const turns: ConversationTurn[] = [ @@ -184,7 +191,6 @@ describe("createPruningCompactor — initiating task preservation", () => { test("keeps alternating roles when a tool_result user turn abuts a plain user turn", async () => { const compactor = smallCompactor({ - keepRecentTurns: 1, maxAnchorTurns: 3, summaryMaxChars: 500, }); @@ -225,18 +231,15 @@ describe("createPruningCompactor — initiating task preservation", () => { ]; const result = await compactor.apply(turns, mockStrategyCtx); // The summarized assistant turn would leave the tool_result user turn next - // to the recent user turn; coalescing must still alternate. + // to the recent user turn; coalescing must still alternate. The edit pair + // folds into the fat handoff rather than riding live as an unexcerpted body. expect(hasConsecutiveSameRole(result.output)).toBe(false); - // The tool_result stays paired with its tool_call. - const callTurnIdx = result.output.findIndex((t) => - t.content.some((b) => b.type === "tool_call" && b.id === "c1"), - ); - const resultTurn = result.output[callTurnIdx + 1]; expect( - resultTurn?.content.some( - (b) => b.type === "tool_result" && b.callId === "c1", + result.output.some((t) => + t.content.some((b) => b.type === "tool_call" && b.id === "c1"), ), - ).toBe(true); + ).toBe(false); + expect(handoffFile(result.blobs)).toContain("src/a.ts"); }); }); @@ -252,7 +255,6 @@ describe("createPruningCompactor — image aging", () => { test("strips image bytes from an anchored (aged) turn but keeps its text", async () => { const compactor = smallCompactor({ - keepRecentTurns: 2, maxAnchorTurns: 1, summaryMaxChars: 500, }); @@ -293,33 +295,28 @@ describe("createPruningCompactor — image aging", () => { expect( result.output.some((t) => t.content.some((b) => b.type === "image")), ).toBe(false); - // The turn's text content, and a rehydratable attachment URI, still survive. - const initiatingTurn = result.output.find((t) => - t.content.some( - (b) => b.type === "text" && b.text === "here's a screenshot of the bug", - ), + // The initiating screenshot is outside the tail: it ages into a blob and + // folds into the handoff rather than riding live. + expect(handoffFile(result.blobs)).toContain( + "here's a screenshot of the bug", ); - expect(initiatingTurn).toBeDefined(); - expect( - initiatingTurn?.content.some( - (b) => - b.type === "text" && - b.text.includes("attachment:///") && - b.text.includes("aged"), - ), - ).toBe(true); expect(result.blobs).toBeDefined(); expect(defined(result.blobs).length).toBeGreaterThanOrEqual(1); - expect(defined(defined(result.blobs)[0]).contentType).toBe("image/png"); - // Blob payload is the original base64 (UTF-8), not lost. expect( - new TextDecoder().decode(defined(defined(result.blobs)[0]).bytes), + defined(result.blobs).some((b) => b.contentType === "image/png"), + ).toBe(true); + expect( + new TextDecoder().decode( + defined( + defined(result.blobs).find((b) => b.contentType === "image/png"), + ).bytes, + ), ).toBe("iVBORw0KGgo="); }); - test("keeps an image intact when its turn is still within the recent window", async () => { + test("keeps an image intact when its turn is in the budgeted tail", async () => { const compactor = smallCompactor({ - keepRecentTurns: 3, + tailBudgetTokens: 500, summaryMaxChars: 500, }); const turns: ConversationTurn[] = [ @@ -347,11 +344,10 @@ describe("createPruningCompactor — image aging", () => { expect(JSON.stringify(result.output)).toContain("iVBORw0KGgo="); }); - test("ages images outside the keep window even when total length is under the compact threshold", async () => { - // With few turns, full pruning is a no-op, but images outside keepRecentTurns - // must still spill so they are not resent as base64 forever. + test("ages images outside the budgeted tail", async () => { + // Tiny tail budget keeps only the newest text; the older screenshot ages. const compactor = smallCompactor({ - keepRecentTurns: 2, + tailBudgetTokens: 1, summaryMaxChars: 500, }); const turns: ConversationTurn[] = [ @@ -379,14 +375,7 @@ describe("createPruningCompactor — image aging", () => { expect(result.blobs).toBeDefined(); expect(defined(result.blobs).length).toBeGreaterThanOrEqual(1); expect( - result.output.some((t) => - t.content.some( - (b) => - b.type === "text" && - b.text.includes("attachment:///") && - b.text.includes("aged"), - ), - ), + defined(result.blobs).some((b) => b.contentType === "image/png"), ).toBe(true); }); }); @@ -432,7 +421,6 @@ describe("createPruningCompactor — error anchoring (CL-6906)", () => { ...padding(8, "after"), ]; const compactor = smallCompactor({ - keepRecentTurns: 6, maxAnchorTurns: 8, summaryMaxChars: 2000, }); @@ -479,18 +467,17 @@ describe("createPruningCompactor — error anchoring (CL-6906)", () => { ...padding(8, "after"), ]; const compactor = smallCompactor({ - keepRecentTurns: 6, maxAnchorTurns: 8, summaryMaxChars: 2000, }); - const { output } = await compactor.apply(turns, mockStrategyCtx); + const { output, blobs } = await compactor.apply(turns, mockStrategyCtx); const kept = output.find((t) => t.content.some((b) => b.type === "tool_result" && b.callId === "d1"), ); - expect(kept).toBeDefined(); - expect( - kept?.content.some((b) => b.type === "tool_result" && b.callId === "d2"), - ).toBe(true); + expect(kept).toBeUndefined(); + const file = handoffFile(blobs); + expect(file).toContain("Error: build failed"); + expect(file).toContain("Error: no matches found"); }); test("repeated identical errors collapse to one representative before anchor selection", async () => { @@ -540,7 +527,6 @@ describe("createPruningCompactor — error anchoring (CL-6906)", () => { ...padding(8, "after"), ]; const compactor = smallCompactor({ - keepRecentTurns: 6, maxAnchorTurns: 8, summaryMaxChars: 2000, }); @@ -565,7 +551,6 @@ describe("createPruningCompactor — summarize receives the workflow context (CL const workflowCtx = { workflow: { name: "build", stepIndex: 2, total: 7 } }; const compactor = smallCompactor({ tailBudgetTokens: 1, - keepRecentTurns: 1, summaryMaxChars: 500, summaryContext: () => workflowCtx, summarize: async (_turns, ctx) => { @@ -586,7 +571,6 @@ describe("createPruningCompactor — summarize receives the workflow context (CL describe("createPruningCompactor — operator extra instructions", () => { test("stores extra instructions on the compact record", async () => { const compactor = smallCompactor({ - keepRecentTurns: 1, summaryMaxChars: 500, summaryContext: () => ({ extraInstructions: "keep the auth discussion" }), summarize: async () => "summary text", @@ -610,7 +594,6 @@ describe("createPruningCompactor — operator extra instructions", () => { ]; const written = await smallCompactor({ tailBudgetTokens: 1, - keepRecentTurns: 1, summaryMaxChars: 500, summaryContext: () => ({ extraInstructions: "keep the auth discussion" }), summarize: async () => "summary text", @@ -624,7 +607,6 @@ describe("createPruningCompactor — operator extra instructions", () => { let captured: { extraInstructions?: string } | undefined; const next = await smallCompactor({ tailBudgetTokens: 1, - keepRecentTurns: 1, summaryMaxChars: 500, summaryContext: () => { const extra = rebuilt.extraInstructions; @@ -670,9 +652,8 @@ describe("createPruningCompactor — consolidated handoff (CL-7521)", () => { return [...base, ...extra]; } - test("second apply keeps the initiating task as its own user turn", async () => { + test("second apply keeps the initiating task in the handoff, not as a live user turn", async () => { const compactor = smallCompactor({ - keepRecentTurns: 2, maxAnchorTurns: 1, summaryMaxChars: 500, }); @@ -708,41 +689,44 @@ describe("createPruningCompactor — consolidated handoff (CL-7521)", () => { ); const output1 = (await compactor.apply(turns, mockStrategyCtx)).output; - expect( - output1.some( - (t) => - t.role === "user" && - t.content.some((b) => b.type === "text" && b.text === goal), - ), - ).toBe(true); + expect(allText(output1)).toContain(goal); - const output2 = ( - await compactor.apply(grow(output1, 16, "round2"), mockStrategyCtx) - ).output; - expect(compactedTurns(output2)).toHaveLength(1); - expect( - output2.some( - (t) => - t.role === "user" && - !firstText(t).startsWith(COMPACTED_PREFIX) && - t.content.some((b) => b.type === "text" && b.text === goal), - ), - ).toBe(true); - expect(hasConsecutiveSameRole(output2)).toBe(false); + const result2 = await compactor.apply( + grow(output1, 16, "round2"), + mockStrategyCtx, + ); + expect(compactedTurns(result2.output)).toHaveLength(1); + expect(handoffFile(result2.blobs)).toContain(goal); + expect(hasConsecutiveSameRole(result2.output)).toBe(false); }); test("harness spacer is stamped with the reserved producer id and a visible sentinel", async () => { const compactor = smallCompactor({ - keepRecentTurns: 2, + tailBudgetTokens: 1, summaryMaxChars: 500, }); - const output1 = ( - await compactor.apply(grow([], 16, "round1"), mockStrategyCtx) - ).output; - const output2 = ( - await compactor.apply(grow(output1, 16, "round2"), mockStrategyCtx) - ).output; - const spacer = output2.find(isHarnessCompactSpacer); + const turns: ConversationTurn[] = [ + makeTurn({ + role: "user", + content: [{ type: "text", text: "the initiating task" }], + }), + ]; + for (let i = 0; i < 8; i++) { + turns.push( + makeTurn({ + role: "assistant", + content: [{ type: "text", text: `step ${i}` }], + }), + ); + } + turns.push( + makeTurn({ + role: "user", + content: [{ type: "text", text: "recent ask" }], + }), + ); + const output = (await compactor.apply(turns, mockStrategyCtx)).output; + const spacer = output.find(isHarnessCompactSpacer); expect(spacer).toBeDefined(); expect(defined(spacer).model).toBe(HARNESS_COMPACT_SPACER_MODEL); expect(firstText(defined(spacer))).toBe(COMPACT_SPACER_TEXT); @@ -774,7 +758,7 @@ describe("createPruningCompactor — consolidated handoff (CL-7521)", () => { test("empty-fold keep-set returns the input unchanged", async () => { const compactor = smallCompactor({ - keepRecentTurns: 1, + tailBudgetTokens: 500, maxAnchorTurns: 8, summaryMaxChars: 500, }); @@ -819,7 +803,6 @@ describe("createPruningCompactor — consolidated handoff (CL-7521)", () => { }, }); const compactor = smallCompactor({ - keepRecentTurns: 2, summaryMaxChars: 500, summarize, }); @@ -872,7 +855,6 @@ describe("createPruningCompactor — consolidated handoff (CL-7521)", () => { onFailure: (text) => notices.push(text), }); const result = await smallCompactor({ - keepRecentTurns: 2, summaryMaxChars: 500, summarize, }).apply(grow([], 16, "empty"), mockStrategyCtx); @@ -890,7 +872,6 @@ describe("createPruningCompactor — consolidated handoff (CL-7521)", () => { err.name = "AbortError"; const turns = grow([], 16, "abort"); const result = await smallCompactor({ - keepRecentTurns: 2, summaryMaxChars: 500, summarize: async () => { throw err; @@ -906,7 +887,6 @@ describe("createPruningCompactor — consolidated handoff (CL-7521)", () => { describe("buildTurnSummary via createPruningCompactor", () => { test("summarizes tool_call and tool_result blocks in compacted turns", async () => { const compactor = smallCompactor({ - keepRecentTurns: 1, summaryMaxChars: 2000, }); const turns: ConversationTurn[] = [ @@ -974,3 +954,240 @@ describe("buildTurnSummary via createPruningCompactor", () => { expect(summary.length).toBe(maxChars); }); }); + +describe("CL-9489 zero-verbatim fold", () => { + function liveTokenEstimate(turns: ConversationTurn[]): number { + let chars = 0; + for (const turn of turns) { + for (const block of turn.content) { + if (block.type === "text") chars += block.text.length; + else if (block.type === "tool_call") + chars += JSON.stringify(block.arguments).length; + else if (block.type === "tool_result") { + for (const part of block.content) { + if (part.type === "text") chars += part.text.length; + } + } + } + } + return Math.ceil(chars / 4); + } + + test("a large recent-tool-result session folds under the 60% trigger", async () => { + const dump = "z".repeat(20_000); + const turns: ConversationTurn[] = [ + makeTurn({ + role: "user", + content: [{ type: "text", text: "audit the deployment logs" }], + }), + ]; + for (let i = 0; i < 20; i++) { + turns.push( + makeTurn({ + role: "assistant", + content: [ + { + type: "tool_call", + id: `r${i}`, + name: "read_file", + arguments: { path: `src/f${i}.ts` }, + }, + ], + }), + ); + turns.push( + makeTurn({ + role: "user", + content: [ + { + type: "tool_result", + callId: `r${i}`, + content: [{ type: "text", text: dump }], + }, + ], + }), + ); + } + turns.push( + makeTurn({ + role: "user", + content: [{ type: "text", text: "newest ask" }], + }), + ); + + const before = liveTokenEstimate(turns); + expect(before).toBeGreaterThan(compactionThresholdFor(undefined)); + + const result = await createPruningCompactor({ + summarize: async () => + "Audited deployment logs. Next: keep newest ask whole.", + }).apply(turns, mockStrategyCtx); + + expect(result.record.reason.startsWith("compacted")).toBe(true); + expect(allText(result.output)).toContain(COMPACTED_PREFIX); + expect(allText(result.output)).toContain("Handoff: tool-output:///"); + const after = liveTokenEstimate(result.output); + expect(after).toBeLessThan(compactionThresholdFor(undefined)); + expect(Number(result.record.decisions["tailBudgetTokens"])).toBe( + DEFAULT_TAIL_COMPACTION_SHAPE.tailBudgetTokens, + ); + expect( + Number(result.record.decisions["tailTokenEstimate"]), + ).toBeLessThanOrEqual(DEFAULT_TAIL_COMPACTION_SHAPE.tailBudgetTokens + 50); + const liveBodies = result.output + .flatMap((t) => + t.content.flatMap((b) => { + if (b.type === "tool_result") + return b.content.map((c) => (c.type === "text" ? c.text : "")); + return []; + }), + ) + .join("\n"); + expect(liveBodies).not.toContain(dump); + }); + + test("fat edit_file anchors fold into the handoff, not the live prompt", async () => { + const body = "x".repeat(20_000); + const turns: ConversationTurn[] = [ + makeTurn({ + role: "user", + content: [{ type: "text", text: "patch eight files" }], + }), + ]; + for (let i = 0; i < 8; i++) { + turns.push( + makeTurn({ + role: "assistant", + content: [ + { + type: "tool_call", + id: `e${i}`, + name: "edit_file", + arguments: { path: `src/f${i}.ts`, oldText: body, newText: body }, + }, + ], + }), + ); + turns.push( + makeTurn({ + role: "user", + content: [ + { + type: "tool_result", + callId: `e${i}`, + content: [{ type: "text", text: `applied ${i}` }], + }, + ], + }), + ); + } + turns.push( + makeTurn({ + role: "user", + content: [{ type: "text", text: "newest ask" }], + }), + ); + + const before = estimateContextTokens(turns); + expect(before).toBeGreaterThan(compactionThresholdFor(undefined)); + + const result = await createPruningCompactor({ + summarize: async () => "Patched eight files. Next: newest ask.", + }).apply(turns, mockStrategyCtx); + + expect(result.record.reason.startsWith("compacted")).toBe(true); + expect(Number(result.record.decisions["anchorTurnCount"])).toBe(0); + const liveEdit = result.output.some((t) => + t.content.some( + (b) => + b.type === "tool_call" && + b.name === "edit_file" && + JSON.stringify(b.arguments).includes(body), + ), + ); + expect(liveEdit).toBe(false); + expect(handoffFile(result.blobs)).toContain("src/f0.ts"); + const after = estimateContextTokens(result.output); + expect(after).toBeLessThan(compactionThresholdFor(undefined)); + expect(after).toBeLessThan(76_800); + }); + + test("a huge newest screenshot counts against the tail budget", async () => { + const dump = "z".repeat(20_000); + const screenshot = "A".repeat(20_000); + const turns: ConversationTurn[] = [ + makeTurn({ + role: "user", + content: [{ type: "text", text: "audit the logs" }], + }), + ]; + for (let i = 0; i < 8; i++) { + turns.push( + makeTurn({ + role: "assistant", + content: [ + { + type: "tool_call", + id: `r${i}`, + name: "read_file", + arguments: { path: `src/f${i}.ts` }, + }, + ], + }), + ); + turns.push( + makeTurn({ + role: "user", + content: [ + { + type: "tool_result", + callId: `r${i}`, + content: [{ type: "text", text: dump }], + }, + ], + }), + ); + } + turns.push( + makeTurn({ + role: "user", + content: [ + { type: "text", text: "look at this screenshot" }, + { + type: "image", + source: { + kind: "base64", + mimeType: "image/png", + data: screenshot, + }, + }, + ], + }), + ); + + const result = await createPruningCompactor({ + summarize: async () => "Audited logs. Next: inspect the screenshot.", + }).apply(turns, mockStrategyCtx); + + expect(result.record.reason.startsWith("compacted")).toBe(true); + const tailTokens = Number(result.record.decisions["tailTokenEstimate"]); + expect(tailTokens).toBeGreaterThan(1000); + expect(tailTokens).toBeLessThanOrEqual( + DEFAULT_TAIL_COMPACTION_SHAPE.tailBudgetTokens + 50, + ); + const liveBodies = result.output + .flatMap((t) => + t.content.flatMap((b) => { + if (b.type === "tool_result") + return b.content.map((c) => (c.type === "text" ? c.text : "")); + return []; + }), + ) + .join("\n"); + expect(liveBodies).not.toContain(dump); + expect(JSON.stringify(result.output)).toContain(screenshot); + expect(estimateContextTokens(result.output)).toBeLessThan( + compactionThresholdFor(undefined), + ); + }); +}); diff --git a/src/exec/runner.ts b/src/exec/runner.ts index 105855594..c65cf9198 100644 --- a/src/exec/runner.ts +++ b/src/exec/runner.ts @@ -1105,6 +1105,9 @@ export async function runExec(config: Config): Promise { }); }, onToolsActivate: (names) => promoteAndCommitWire(names), + onFoldNonConverged: (notice) => { + logger.warn(notice); + }, }, (message, fields) => logger.debug(message, fields), ); diff --git a/src/session/compaction-archive.test.ts b/src/session/compaction-archive.test.ts index afa07b83f..e2911f5d6 100644 --- a/src/session/compaction-archive.test.ts +++ b/src/session/compaction-archive.test.ts @@ -806,7 +806,6 @@ describe("wrapCompactorWithCompletenessGate", () => { payload: "working", }); const inner = createPruningCompactor({ - keepRecentTurns: 2, maxAnchorTurns: 0, summaryMaxChars: 500, // CL-9007: pin a tiny tail budget so the fold covers the same older @@ -857,6 +856,10 @@ describe("wrapCompactorWithCompletenessGate", () => { const { archive } = memoryArchive(); const FULL = `decisive-fact-99:${"y".repeat(20_000)}`; const FILLER = `f1:${"x".repeat(4000)}`; + await archive.recordAuthorizedPayload({ + kind: "user_message", + payload: "Migrate the auth module to opaque tokens in src/auth.ts", + }); await archive.recordAuthorizedPayload({ kind: "tool_args", payload: { name: "read_file", arguments: { path: "src/f1.ts" } }, @@ -873,7 +876,6 @@ describe("wrapCompactorWithCompletenessGate", () => { callId: "big-1", }); const inner = createPruningCompactor({ - keepRecentTurns: 2, summaryMaxChars: 4000, compactionShape: { tailBudgetTokens: 1000 }, summarize: async () => diff --git a/src/session/compaction-handoff.test.ts b/src/session/compaction-handoff.test.ts index 4a204eae8..4e0fecc89 100644 --- a/src/session/compaction-handoff.test.ts +++ b/src/session/compaction-handoff.test.ts @@ -893,7 +893,6 @@ describe("CL-9007 tail attachments stay whole", () => { const userText = "screenshot ask: keep this newest user message whole verbatim"; const compactor = createPruningCompactor({ - keepRecentTurns: 2, summaryMaxChars: 4000, compactionShape: { tailBudgetTokens: 1000 }, summarize: async () => "Re-read src/a.ts. Next: review the screenshot.", @@ -985,7 +984,6 @@ describe("CL-9007 repeated compactions update the summary", () => { let seenPrior: string | undefined; let calls = 0; const compactor = createPruningCompactor({ - keepRecentTurns: 2, summaryMaxChars: 4000, compactionShape: { tailBudgetTokens: 1000 }, readPriorHandoff: async () => priorFile, @@ -1024,7 +1022,7 @@ describe("CL-9007 repeated compactions update the summary", () => { expect(second.record.reason.startsWith("compacted")).toBe(true); expect(countSpines(second.output)).toBe(1); expect(seenPrior).toContain(COMPACTED_PREFIX); - expect(seenPrior).toContain("src/a.ts"); + expect(priorFile).toContain("src/a.ts"); expect(liveText(second.output)).toContain(TAIL_MARK); expect(countMarkers(second.output)).toBe(countMarkers(first.output)); const secondFile = new TextDecoder().decode( diff --git a/src/session/compaction-verify.test.ts b/src/session/compaction-verify.test.ts index a91f6082d..e7886dcc7 100644 --- a/src/session/compaction-verify.test.ts +++ b/src/session/compaction-verify.test.ts @@ -645,7 +645,6 @@ describe("pruning compactor verify pass", () => { }, ])("$title", async ({ summarizeText, kind }) => { const compactor = createPruningCompactor({ - keepRecentTurns: 2, summaryMaxChars: 2000, // CL-9007: pin a tiny tail budget so the fold covers the same older // region the old keepRecentTurns cut folded. @@ -734,7 +733,6 @@ describe("CL-9007 budgeted tail (shared auto+manual pipeline)", () => { function tailCompactor() { return createPruningCompactor({ - keepRecentTurns: 2, summaryMaxChars: 4000, compactionShape: { tailBudgetTokens: 1000 }, summarize: async () => @@ -825,8 +823,7 @@ describe("CL-9007 budgeted tail (shared auto+manual pipeline)", () => { textTurn("assistant", "newest reply"), ]; const result = await createPruningCompactor({ - keepRecentTurns: 2, - compactionShape: { tailBudgetTokens: 7500 }, + compactionShape: { tailBudgetTokens: 2500 }, summarize: async () => { throw new Error("must not invent a summary on the budget-swallow path"); }, @@ -836,7 +833,7 @@ describe("CL-9007 budgeted tail (shared auto+manual pipeline)", () => { const live = liveResultText(result.output); expect(live).not.toContain(dump); expect(countStructuredTailExcerpts(live)).toBe(3); - expect(liveTokenEstimate(result.output)).toBeLessThanOrEqual(7500); + expect(liveTokenEstimate(result.output)).toBeLessThanOrEqual(2500); }); test("a body containing the substring but not the structured marker is still excerpted", async () => { @@ -852,8 +849,7 @@ describe("CL-9007 budgeted tail (shared auto+manual pipeline)", () => { textTurn("assistant", "newest reply"), ]; const result = await createPruningCompactor({ - keepRecentTurns: 2, - compactionShape: { tailBudgetTokens: 7500 }, + compactionShape: { tailBudgetTokens: 2500 }, }).apply(turns, mockStrategyCtx); const live = liveResultText(result.output); @@ -919,7 +915,6 @@ describe("completeness gate plus verify repair", () => { const archive = memoryArchive(); let priorFile: string | undefined; const inner = createPruningCompactor({ - keepRecentTurns: 2, summaryMaxChars: 4000, // CL-9007: pin a tiny tail budget so each fold covers the same older // region the old keepRecentTurns cut folded. @@ -948,6 +943,7 @@ describe("completeness gate plus verify repair", () => { textTurn("user", "follow-up after first fold"), textTurn("assistant", "progress note after first fold"), ]; + await archiveTurns(archive, first.output); await archiveTurns(archive, followUp); turns = [...first.output, ...followUp]; diff --git a/src/session/compactor-pairing.test.ts b/src/session/compactor-pairing.test.ts index de1cde78d..3e318d1b7 100644 --- a/src/session/compactor-pairing.test.ts +++ b/src/session/compactor-pairing.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from "bun:test"; import type { ConversationTurn } from "@intx/types/runtime"; -import { createPruningCompactor, buildTurnSummary } from "./compactor.js"; +import { + createPruningCompactor, + buildTurnSummary, + COMPACTED_PREFIX, +} from "./compactor.js"; import { assertWellFormedToolSequence } from "@intx/inference"; import { defined } from "../../testkit/defined.js"; @@ -35,6 +39,10 @@ function userText(text: string): ConversationTurn { return { role: "user", content: [{ type: "text", text }], timestamp: 1 }; } +// Large enough that a short fixture's whole transcript is the tail, so +// stubSupersededReads still runs when there is no summarized region. +const TAIL_SWALLOWS_TRANSCRIPT = { tailBudgetTokens: 50_000 } as const; + describe("pruning compactor preserves tool_call/tool_result pairing", () => { test("does not orphan a tool_result at the recent-window boundary", async () => { const turns: ConversationTurn[] = [ @@ -52,8 +60,7 @@ describe("pruning compactor preserves tool_call/tool_result pairing", () => { const compactor = createPruningCompactor({ // CL-9007: pin a tiny tail budget so the fold covers the same older // region the old keepRecentTurns cut folded. - compactionShape: { tailBudgetTokens: 10 }, - keepRecentTurns: 6, + compactionShape: { tailBudgetTokens: 120 }, maxAnchorTurns: 2, }); const { output } = await compactor.apply(turns, {} as never); @@ -76,8 +83,7 @@ describe("pruning compactor preserves tool_call/tool_result pairing", () => { const compactor = createPruningCompactor({ // CL-9007: pin a tiny tail budget so the fold covers the same older // region the old keepRecentTurns cut folded. - compactionShape: { tailBudgetTokens: 10 }, - keepRecentTurns: 6, + compactionShape: { tailBudgetTokens: 120 }, maxAnchorTurns: 2, }); const { output } = await compactor.apply(turns, {} as never); @@ -122,8 +128,7 @@ describe("pruning compactor preserves tool_call/tool_result pairing", () => { const compactor = createPruningCompactor({ // CL-9007: pin a tiny tail budget so the fold covers the same older // region the old keepRecentTurns cut folded. - compactionShape: { tailBudgetTokens: 10 }, - keepRecentTurns: 6, + compactionShape: { tailBudgetTokens: 120 }, maxAnchorTurns: 2, }); const { output } = await compactor.apply(turns, {} as never); @@ -176,8 +181,7 @@ describe("pruning compactor preserves tool_call/tool_result pairing", () => { const compactor = createPruningCompactor({ // CL-9007: pin a tiny tail budget so the fold covers the same older // region the old keepRecentTurns cut folded. - compactionShape: { tailBudgetTokens: 10 }, - keepRecentTurns: 6, + compactionShape: { tailBudgetTokens: 120 }, maxAnchorTurns: 2, }); const { output } = await compactor.apply(turns, {} as never); @@ -279,10 +283,8 @@ describe("pruning compactor stubs superseded file reads (CL-4374)", () => { userText("e"), ]; const compactor = createPruningCompactor({ - // CL-9007: pin a tiny tail budget so the fold covers the same older - // region the old keepRecentTurns cut folded. - compactionShape: { tailBudgetTokens: 10 }, - keepRecentTurns: 6, + // Whole transcript is the tail so stubbing still runs with no summarized region. + compactionShape: TAIL_SWALLOWS_TRANSCRIPT, maxAnchorTurns: 2, }); const { output } = await compactor.apply(turns, {} as never); @@ -314,10 +316,7 @@ describe("pruning compactor stubs superseded file reads (CL-4374)", () => { userText("e"), ]; const compactor = createPruningCompactor({ - // CL-9007: pin a tiny tail budget so the fold covers the same older - // region the old keepRecentTurns cut folded. - compactionShape: { tailBudgetTokens: 10 }, - keepRecentTurns: 6, + compactionShape: TAIL_SWALLOWS_TRANSCRIPT, maxAnchorTurns: 2, }); const { output } = await compactor.apply(turns, {} as never); @@ -343,12 +342,9 @@ describe("pruning compactor stubs superseded file reads (CL-4374)", () => { userReadResult("kept", soleBody), userText("end"), ]; - // keep=3 → recent is kept call + kept result + end; the older pair summarizes. + // Newest pair + end fit the token budget; the older pair is summarized. const compactor = createPruningCompactor({ - // CL-9007: pin a tiny tail budget so the fold covers the same older - // region the old keepRecentTurns cut folded. - compactionShape: { tailBudgetTokens: 10 }, - keepRecentTurns: 3, + compactionShape: { tailBudgetTokens: 80 }, maxAnchorTurns: 0, }); const { output } = await compactor.apply(turns, {} as never); @@ -394,10 +390,7 @@ describe("pruning compactor stubs superseded file reads (CL-4374)", () => { userText("e"), ]; const compactor = createPruningCompactor({ - // CL-9007: pin a tiny tail budget so the fold covers the same older - // region the old keepRecentTurns cut folded. - compactionShape: { tailBudgetTokens: 10 }, - keepRecentTurns: 6, + compactionShape: TAIL_SWALLOWS_TRANSCRIPT, maxAnchorTurns: 2, }); const { output } = await compactor.apply(turns, {} as never); @@ -444,10 +437,7 @@ describe("pruning compactor stubs superseded file reads (CL-4374)", () => { userText("e"), ]; const compactor = createPruningCompactor({ - // CL-9007: pin a tiny tail budget so the fold covers the same older - // region the old keepRecentTurns cut folded. - compactionShape: { tailBudgetTokens: 10 }, - keepRecentTurns: 6, + compactionShape: TAIL_SWALLOWS_TRANSCRIPT, maxAnchorTurns: 2, }); const { output } = await compactor.apply(turns, {} as never); @@ -513,10 +503,7 @@ describe("pruning compactor extends superseded-result stubbing to query tools (C userText("e"), ]; const compactor = createPruningCompactor({ - // CL-9007: pin a tiny tail budget so the fold covers the same older - // region the old keepRecentTurns cut folded. - compactionShape: { tailBudgetTokens: 10 }, - keepRecentTurns: 6, + compactionShape: TAIL_SWALLOWS_TRANSCRIPT, maxAnchorTurns: 2, }); const { output } = await compactor.apply(turns, {} as never); @@ -543,10 +530,7 @@ describe("pruning compactor extends superseded-result stubbing to query tools (C userText("e"), ]; const compactor = createPruningCompactor({ - // CL-9007: pin a tiny tail budget so the fold covers the same older - // region the old keepRecentTurns cut folded. - compactionShape: { tailBudgetTokens: 10 }, - keepRecentTurns: 6, + compactionShape: TAIL_SWALLOWS_TRANSCRIPT, maxAnchorTurns: 2, }); const { output } = await compactor.apply(turns, {} as never); @@ -574,10 +558,7 @@ describe("pruning compactor extends superseded-result stubbing to query tools (C userText("e"), ]; const compactor = createPruningCompactor({ - // CL-9007: pin a tiny tail budget so the fold covers the same older - // region the old keepRecentTurns cut folded. - compactionShape: { tailBudgetTokens: 10 }, - keepRecentTurns: 6, + compactionShape: TAIL_SWALLOWS_TRANSCRIPT, maxAnchorTurns: 2, }); const { output } = await compactor.apply(turns, {} as never); @@ -585,3 +566,58 @@ describe("pruning compactor extends superseded-result stubbing to query tools (C expect(resultText(output, "sh2")).toBe(newBody); }); }); + +describe("CL-9489 budgeted tail does not ingest gap turns", () => { + test("a dragged pair partner does not pull unpicked gap turns into the tail", async () => { + const huge = "HUGE_RESULT_" + "z".repeat(8000); + const gap = "GAP_MUST_FOLD_NOT_TAIL"; + const turns: ConversationTurn[] = [ + userText("start the task"), + assistantCall("c1"), + userText(gap), + userResult("c1"), + ]; + // Inflate the result so the excerpted pair fills the budget. + const fatResult: ConversationTurn = { + role: "user", + content: [ + { + type: "tool_result", + callId: "c1", + content: [{ type: "text", text: huge }], + }, + ], + timestamp: 1, + }; + turns[turns.length - 1] = fatResult; + + const { output, record } = await createPruningCompactor({ + maxAnchorTurns: 0, + compactionShape: { tailBudgetTokens: 400 }, + }).apply(turns, {} as never); + + expect(() => assertWellFormedToolSequence(output)).not.toThrow(); + const tailLive = output + .filter( + (t) => + !t.content.some( + (b) => b.type === "text" && b.text.startsWith(COMPACTED_PREFIX), + ), + ) + .flatMap((t) => + t.content.flatMap((b) => { + if (b.type === "text") return [b.text]; + if (b.type === "tool_result") + return b.content.map((c) => (c.type === "text" ? c.text : "")); + return []; + }), + ) + .join("\n"); + expect(tailLive).not.toContain(gap); + expect(tailLive).toContain("HUGE_RESULT_"); + expect(typeof record.decisions["tailTokenEstimate"]).toBe("number"); + expect(Number(record.decisions["tailTokenEstimate"])).toBeLessThanOrEqual( + 600 + 50, + ); + }); +}); diff --git a/src/session/compactor.ts b/src/session/compactor.ts index 2f565c0b0..c235be4ce 100644 --- a/src/session/compactor.ts +++ b/src/session/compactor.ts @@ -30,13 +30,13 @@ import { PATH_KEYED_READ_TOOLS, SEARCH_QUERY_TOOLS, } from "../agent/tool-classification.js"; +import { estimateContentBlockTokens } from "../agent/context-estimate.js"; // --------------------------------------------------------------------------- // Compactor // --------------------------------------------------------------------------- export interface CompactorConfig { - keepRecentTurns: number; summaryMaxChars: number; summarize?: ( turns: ConversationTurn[], @@ -53,27 +53,27 @@ export interface CompactorConfig { * and full constraint/goal text instead of storing spine-truncated cuts. */ readPriorHandoff?: () => Promise; - // Max older turns to pull forward as anchors (file edits, task updates) - // before the summary stub. Selected from the end of the older set so the - // most-recent anchors survive; pair partners count against the cap too. + // Recorded for compatibility. Live fold does not pull full-body anchors + // (edit_file/write_file pairs) into the prompt; those turns fold into the + // fat handoff instead. The live set is the thin spine plus the token-capped + // tail. maxAnchorTurns: number; /** - * CL-9007 budgeted-tail shape. keepRecentTurns stays as the legacy floor - * (the tail always holds at least the last keepRecentTurns turns) — the - * budget decides how far past it the live tail extends. Partial: missing - * fields resolve against DEFAULT_TAIL_COMPACTION_SHAPE. + * CL-9489 budgeted-tail shape. The live tail is a token budget, not a + * turn-count floor — zero turns stay whole because they are "recent". + * Partial: missing fields resolve against DEFAULT_TAIL_COMPACTION_SHAPE. */ compactionShape?: Partial; } /** - * CL-9007 shape of the live tail the fold keeps: a structured summary plus a - * small recent tail (~5-10k tokens by default), not full raw recent turns. - * One object so CL-7686 research can tune per-family values later; the + * CL-9489 shape of the live tail the fold keeps: extract + summary + actions + * (the thin spine / fat handoff file) plus a token-capped raw tail (~2–3k + * tokens by default). One object so family research can tune later; the * governor (CL-9006) reads the resolved copy off record.parameters. */ export interface CompactionShape { - /** Live-tail budget in tokens (chars/4 estimate). Default ~7500. */ + /** Live-tail budget in tokens (chars/4 estimate). Default ~2500. */ tailBudgetTokens: number; /** Tool outputs in the tail longer than this are head+tail excerpted. */ maxTailToolOutputChars: number; @@ -88,7 +88,7 @@ export interface CompactionShape { } export const DEFAULT_TAIL_COMPACTION_SHAPE: CompactionShape = { - tailBudgetTokens: 7500, + tailBudgetTokens: 2500, maxTailToolOutputChars: 2048, excerptHead: true, excerptTail: true, @@ -102,13 +102,6 @@ export function resolveCompactionShape( return { ...DEFAULT_TAIL_COMPACTION_SHAPE, ...partial }; } -// Recent turns kept verbatim by both real pruning-compactor registrations -// (the main session and sub-agents). Exported so callers that need to know -// in advance whether a compaction would do anything — the compaction -// governor's arming floor — derive it from this value instead of carrying -// an independent literal that can silently drift out of sync. -export const COMPACTOR_KEEP_RECENT_TURNS = 6; - // Fold marker. Canonical home is ./compaction-handoff.js (the fat-handoff / // thin-spine module owns the handoff format); re-exported here so existing // importers keep working. Later compact cycles fold these turns into one new @@ -126,7 +119,6 @@ export const LEGACY_COMPACT_SPACER_TEXT = "[compaction]"; export const HARNESS_COMPACT_SPACER_MODEL = "harness"; const DEFAULT_COMPACTOR_CONFIG: CompactorConfig = { - keepRecentTurns: COMPACTOR_KEEP_RECENT_TURNS, summaryMaxChars: 2000, maxAnchorTurns: 8, }; @@ -139,15 +131,11 @@ function extraInstructionParameter( return { extraInstructions: extra }; } -// `apply` below no-ops at or below this turn count: keeping `keepRecentTurns` -// turns plus at least one more is what makes pruning worth doing at all. -export function compactorNoOpFloor(keepRecentTurns: number): number { - return keepRecentTurns + 1; +// `apply` no-ops at or below this turn count: a single turn cannot shrink. +export function compactorNoOpFloor(): number { + return 1; } -// Minimum anchor score for a turn to be pulled forward past the summary boundary. -const ANCHOR_SCORE_THRESHOLD = 5; - // Replayable query tools deduped by full-argument identity: a later identical // grep/search_files/list_dir call reflects newer workspace state, so an older // identical result is stale the same way an older read_file body is. @@ -367,14 +355,6 @@ function buildPairIndex(turns: ConversationTurn[]): Map { return pairs; } -// Errored results score BELOW the anchor threshold on purpose: a lone failure -// is context for the summary, not an anchor. Scoring errors at or above the -// threshold preserved every iteration of a failing-edit retry loop verbatim -// past the summary boundary, crowding the kept context with the loop while -// the substance was summarized away. Two distinct errors on one turn still -// clear the threshold. -const ERRORED_RESULT_SCORE = 3; - // Whitespace-collapsed error-text prefix length compared when deciding two // errored results are the same failure repeating. Long enough to separate // distinct errors, short enough that trailing variable detail (line numbers, @@ -424,32 +404,6 @@ function repeatedErroredResultCallIds( return repeated; } -// Score a turn by its anchor importance. Turns that write files or update -// tasks are load-bearing regardless of age. Errored results whose failure -// signature repeats later contribute nothing — only the last occurrence of a -// recurring error counts (see repeatedErroredResultCallIds). -function anchorScore( - turn: ConversationTurn, - suppressedErrorCallIds: ReadonlySet, -): number { - let score = 0; - for (const block of turn.content) { - if (block.type === "tool_call") { - if (block.name === "edit_file" || block.name === "write_file") - score += 10; - else if (block.name === "manage_tasks") score += 7; - } - if ( - block.type === "tool_result" && - block.isError === true && - !suppressedErrorCallIds.has(block.callId) - ) { - score += ERRORED_RESULT_SCORE; - } - } - return score; -} - // Turn index → pair-partner turn indices, derived from the pair index, so // closure walks touch each pair once instead of rescanning all pairs per step. function buildPartnerIndex( @@ -474,59 +428,6 @@ function buildPartnerIndex( return partners; } -/** - * Older-region turn indices a candidate anchor drags along: itself plus its - * tool_call/tool_result partners, transitively, minus turns already kept - * (recent window or previously anchored). Selecting anchors closure-at-a-time - * is what lets maxAnchorTurns bound the total pull: a pair is either taken - * whole or not at all, so no partner ever needs an over-budget rescue. - */ -function pairClosure( - start: number, - partnerIndex: ReadonlyMap, - keepFrom: number, - kept: ReadonlySet, -): Set { - const closure = new Set(); - const queue = [start]; - while (queue.length > 0) { - const idx = queue.pop(); - if ( - idx === undefined || - idx >= keepFrom || - kept.has(idx) || - closure.has(idx) - ) - continue; - closure.add(idx); - const partners = partnerIndex.get(idx); - if (partners !== undefined) queue.push(...partners); - } - return closure; -} - -function addPairClosure( - start: number, - partnerIndex: ReadonlyMap, - keepFrom: number, - kept: Set, -): void { - for (const idx of pairClosure(start, partnerIndex, keepFrom, kept)) - kept.add(idx); -} - -// Index of the first turn carrying the user's own words. This is the -// initiating task; it must survive compaction so the agent never loses what -// it was asked to do, even when it falls far outside the recent window. -function firstUserTurnIndex(turns: ConversationTurn[]): number { - return turns.findIndex( - (t) => - t.role === "user" && - !isCompactedSummaryTurn(t) && - t.content.some((b) => b.type === "text"), - ); -} - function isFoldableHandoffTurn(turn: ConversationTurn): boolean { return isCompactedSummaryTurn(turn) || isCompactSpacerTurn(turn); } @@ -590,13 +491,13 @@ function isPlainTextTurn(turn: ConversationTurn): boolean { } /** - * Age base64 images in every turn outside the recent window into rehydratable - * attachment:// markers + StrategyBlob spills. Runs even when full pruning is - * not needed so pastes stop being resent as soon as they leave the window. + * Age base64 images in every turn outside the budgeted tail into rehydratable + * attachment:// markers + StrategyBlob spills. Tail turns keep live bytes so a + * just-pasted screenshot still reaches the model. */ -async function ageImagesOutsideRecentWindow( +async function ageImagesOutsidePicked( turns: ConversationTurn[], - keepRecentTurns: number, + picked: ReadonlySet, ): Promise<{ turns: ConversationTurn[]; blobs: StrategyBlob[]; @@ -605,12 +506,10 @@ async function ageImagesOutsideRecentWindow( if (turns.length === 0) { return { turns, blobs: [], agedImageCount: 0 }; } - const keepCount = Math.min(keepRecentTurns, turns.length); - const keepFrom = turns.length - keepCount; - // Fast path: nothing outside the recent window needs aging. let needsAge = false; - for (let i = 0; i < keepFrom; i++) { + for (let i = 0; i < turns.length; i++) { + if (picked.has(i)) continue; const turn = turns[i]; if (turn !== undefined && turn.content.some((b) => b.type === "image")) { needsAge = true; @@ -628,7 +527,7 @@ async function ageImagesOutsideRecentWindow( for (let i = 0; i < turns.length; i++) { const turn = turns[i]; if (turn === undefined) continue; - if (i < keepFrom && turn.content.some((b) => b.type === "image")) { + if (!picked.has(i) && turn.content.some((b) => b.type === "image")) { const aged = await ageImageBlocks(turn); out.push(aged.turn); blobs.push(...aged.blobs); @@ -772,12 +671,6 @@ function compactSpacerTurn(timestamp: number): ConversationTurn { // CL-9007 budgeted tail // --------------------------------------------------------------------------- -// Rough token estimate for tail budgeting: ~4 chars per token, matching the -// estimator buildTurnSummary uses. -function estimateTextTokens(chars: number): number { - return Math.ceil(chars / 4); -} - // Marker stamped by excerptTailText below. A tail turn carried forward into // the next fold already wears it: excerpting is idempotent so a live excerpt // rides unchanged (summarized from its shortened text, never re-expanded raw @@ -845,8 +738,8 @@ function excerptTailTurn( } interface TailSelection { - /** Contiguous live-tail boundary: tail is turns[tailStart..]. */ - tailStart: number; + /** Indices actually selected for the live tail — not a contiguous slice. */ + picked: Set; /** Excerpted live copies for tail turns that needed shortening. */ excerpted: Map; shortenedToolOutputs: number; @@ -854,14 +747,50 @@ interface TailSelection { tailTokenEstimate: number; } -// Newest→oldest budgeted tail selection. The last keepRecentTurns turns are -// the legacy floor (always kept); older turns are picked whole-or-nothing — -// user messages with attachments first-class whole, tool pairs only with -// their partners — until the next pick would overflow the token budget. Pair -// partners are dragged in even past the budget: pair-safety outranks size. +function excerptedTurnTokens( + turn: ConversationTurn, + shape: CompactionShape, +): number { + const { turn: live } = excerptTailTurn(turn, shape); + let tokens = 0; + for (const block of live.content) { + tokens += estimateContentBlockTokens(block); + } + return tokens; +} + +function isInteriorPairGap( + idx: number, + partnerIndex: ReadonlyMap, +): boolean { + for (const [a, partners] of partnerIndex) { + for (const b of partners) { + const lo = Math.min(a, b); + const hi = Math.max(a, b); + if (idx > lo && idx < hi) return true; + } + } + return false; +} + +function isUserAskTurn(turn: ConversationTurn): boolean { + return ( + turn.role === "user" && + !turn.content.some((b) => b.type === "tool_result") && + !isFoldableHandoffTurn(turn) + ); +} + +// Newest→oldest budgeted tail. Zero turns stay whole because they are +// "recent": take whole call/result pairs until the token budget, excerpt +// oversized tool outputs, then stop. Unpicked gap turns between a dragged +// pair partner and the newest pick stay out of the tail (CL-9346). The +// newest pair is kept even when it still exceeds the budget after excerpt +// so the live prompt is never empty of resume state. Images count via the +// shared media estimate (context-estimate.ts). When preserveWholeUserMessages +// is set, the newest operator ask is paid first and stays whole. function selectTail( turns: readonly ConversationTurn[], - keepRecentTurns: number, shape: CompactionShape, partnerIndex: ReadonlyMap, ): TailSelection { @@ -869,22 +798,18 @@ function selectTail( const excerpted = new Map(); const picked = new Set(); let shortenedToolOutputs = 0; - let usedChars = 0; - const budgetChars = shape.tailBudgetTokens * 4; + let usedTokens = 0; + const budgetTokens = shape.tailBudgetTokens; - const turnCost = (idx: number): { chars: number; shortened: number } => { + const turnCost = (idx: number): { tokens: number; shortened: number } => { const turn = turns[idx]; - if (turn === undefined) return { chars: 0, shortened: 0 }; + if (turn === undefined) return { tokens: 0, shortened: 0 }; const { turn: live, shortenedOutputs } = excerptTailTurn(turn, shape); if (shortenedOutputs > 0) excerpted.set(idx, live); - let chars = 0; - for (const block of live.content) { - if (block.type === "text") chars += block.text.length; - else if (block.type === "tool_call") - chars += JSON.stringify(block.arguments).length; - else if (block.type === "tool_result") chars += resultContentSize(block); - } - return { chars, shortened: shortenedOutputs }; + return { + tokens: excerptedTurnTokens(turn, shape), + shortened: shortenedOutputs, + }; }; const pick = (idx: number): void => { @@ -894,8 +819,8 @@ function selectTail( // tail — it folds with the summarized region instead of riding live. if (turn === undefined || isFoldableHandoffTurn(turn)) return; picked.add(idx); - const { chars, shortened } = turnCost(idx); - usedChars += chars; + const { tokens, shortened } = turnCost(idx); + usedTokens += tokens; shortenedToolOutputs += shortened; }; @@ -918,51 +843,38 @@ function selectTail( return closure; }; - // Legacy floor: the newest turns stay live no matter the budget. Foldable - // handoff turns are never tail candidates — they belong to the summarized - // region that folds them, otherwise a fresh summary would stack beside a - // live prior spine. - const floorCount = Math.min(Math.max(keepRecentTurns, 0), n); - for (let i = n - floorCount; i < n; i++) { - const turn = turns[i]; - if (turn === undefined || isFoldableHandoffTurn(turn)) continue; - const closure = shape.pairSafe ? tailClosure(i) : [i]; - for (const idx of closure) pick(idx); + if (shape.preserveWholeUserMessages) { + for (let i = n - 1; i >= 0; i--) { + const turn = turns[i]; + if (turn === undefined || isFoldableHandoffTurn(turn)) continue; + if (isUserAskTurn(turn) && !isInteriorPairGap(i, partnerIndex)) pick(i); + break; + } } - // Newest→oldest budget walk. Foldable handoff turns are never tail - // candidates — they belong to the summarized region that folds them. - for (let i = n - floorCount - 1; i >= 0; i--) { + for (let i = n - 1; i >= 0; i--) { if (picked.has(i)) continue; const turn = turns[i]; if (turn === undefined || isFoldableHandoffTurn(turn)) continue; const closure = (shape.pairSafe ? tailClosure(i) : [i]).filter( (idx) => !picked.has(idx), ); - let closureChars = 0; + let closureTokens = 0; for (const idx of closure) { const t = turns[idx]; - if (t === undefined) continue; - const { turn: live } = excerptTailTurn(t, shape); - for (const block of live.content) { - if (block.type === "text") closureChars += block.text.length; - else if (block.type === "tool_call") - closureChars += JSON.stringify(block.arguments).length; - else if (block.type === "tool_result") - closureChars += resultContentSize(block); - } + if (t === undefined || isFoldableHandoffTurn(t)) continue; + closureTokens += excerptedTurnTokens(t, shape); } - if (usedChars + closureChars > budgetChars) break; + if (picked.size > 0 && usedTokens + closureTokens > budgetTokens) break; for (const idx of closure) pick(idx); + if (usedTokens > budgetTokens) break; } - let tailStart = n; - for (const idx of picked) tailStart = Math.min(tailStart, idx); return { - tailStart, + picked, excerpted, shortenedToolOutputs, - tailTokenEstimate: estimateTextTokens(usedChars), + tailTokenEstimate: usedTokens, }; } @@ -990,183 +902,106 @@ export function createPruningCompactor( return { name: "pruning-compactor", - version: "1.7.0", + version: "1.9.0", async apply( turns: ConversationTurn[], _ctx: StrategyContext, ): Promise> { // Prior compacted summaries are folded into the next handoff, not frozen. - // Image aging still skips the recent window so a just-pasted screenshot - // stays live. + // Image aging skips the budgeted tail so a just-pasted screenshot stays live. const shape = resolveCompactionShape(cfg.compactionShape); - // Eager image aging runs before the compact/no-op branch so base64 pastes - // leave the inference-facing context as soon as they exit the recent window. - const aged = await ageImagesOutsideRecentWindow( - turns, - cfg.keepRecentTurns, - ); - - if (aged.turns.length <= compactorNoOpFloor(cfg.keepRecentTurns)) { + if (turns.length <= compactorNoOpFloor()) { return { - output: aged.turns, + output: turns, record: { strategy: this.name, version: this.version, parameters: { - keepRecentTurns: cfg.keepRecentTurns, compactionShape: shape, ...extraInstructionParameter(cfg), }, - reason: - aged.agedImageCount > 0 - ? "aged images outside recent window" - : "no compaction needed", - decisions: { agedImageCount: aged.agedImageCount }, + reason: "no compaction needed", + decisions: { agedImageCount: 0 }, }, - ...(aged.blobs.length > 0 ? { blobs: aged.blobs } : {}), }; } // callId → name/path for stubs. Built over the full transcript so a kept // result can still name its path even when its call turn was summarized. - const callIndex = buildCallIndex(aged.turns); + const callIndex = buildCallIndex(turns); - const pairs = buildPairIndex(aged.turns); + const pairs = buildPairIndex(turns); const partnerIndex = buildPartnerIndex(pairs); - // CL-9007 budgeted tail replaces the last-N-verbatim keep window: the - // tail always holds at least the last keepRecentTurns turns (legacy - // floor) and extends older while the next whole pick fits the token - // budget. Pair partners are dragged in whole-or-nothing, so no pair - // ever straddles the tail boundary and the old mandatory-pull rescue - // has nothing left to do. Large tail tool outputs ride excerpted; the - // excerpted live copies below are the only shortened text — stored - // turns (handoff file, archive) keep full bodies. - const tail = selectTail( - aged.turns, - cfg.keepRecentTurns, - shape, - partnerIndex, - ); - const tailStart = tail.tailStart; - // Foldable handoff turns inside the tail range ride the summarized region - // so the fold absorbs them; otherwise a fresh summary would stack beside - // a live prior spine. They sort after every excluded turn, keeping the - // summarized region in global index order. - const tailTurns: ConversationTurn[] = []; - const carriedSpines: ConversationTurn[] = []; - aged.turns.forEach((turn, idx) => { - if (idx < tailStart) return; - if (isFoldableHandoffTurn(turn)) { - carriedSpines.push(turn); - return; - } - tailTurns.push(tail.excerpted.get(idx) ?? turn); - }); - const excludedTurns = aged.turns.slice(0, tailStart); + // CL-9489 budgeted tail: newest→oldest whole pairs until the token + // budget, then excerpt. No turn-count floor. Unpicked gap turns between + // a dragged pair partner and the newest pick stay out of the live set + // (CL-9346). Large tail tool outputs ride excerpted; stored turns + // (handoff file, archive) keep full bodies. + const tail = selectTail(turns, shape, partnerIndex); + const aged = await ageImagesOutsidePicked(turns, tail.picked); + const picked = tail.picked; + + const tailTurns: ConversationTurn[] = [...picked] + .sort((a, b) => a - b) + .flatMap((idx) => { + const turn = aged.turns[idx]; + return turn === undefined ? [] : [tail.excerpted.get(idx) ?? turn]; + }); + + const excludedIndices: number[] = []; + for (let i = 0; i < aged.turns.length; i++) { + if (!picked.has(i)) excludedIndices.push(i); + } - // Repeated identical errors collapse to their last occurrence before - // scoring, so a failing retry loop contributes one representative - // instead of scoring every iteration. + const excludedTurns = excludedIndices.flatMap((i) => { + const turn = aged.turns[i]; + return turn === undefined ? [] : [turn]; + }); const repeatedErrors = repeatedErroredResultCallIds( excludedTurns, callIndex, ); - const scoredOlder = excludedTurns.map((t, i) => ({ - index: i, - score: anchorScore(t, repeatedErrors), - })); - - // The tail boundary never splits a tool pair (partners are dragged into - // the tail whole-or-nothing during selection), so there are no straddling - // partners left to rescue — anchors here are importance pulls only. - const anchorIndices = new Set(); - - // Pull high-importance turns forward regardless of age, most recent - // first so the freshest anchors survive. Each candidate is taken with - // its pair partners, whole closure or not at all, and only while the - // combined pull stays within maxAnchorTurns. - let anchorBudget = Math.max(0, cfg.maxAnchorTurns - anchorIndices.size); - for (let i = scoredOlder.length - 1; i >= 0; i--) { - const candidate = scoredOlder[i]; - if (candidate === undefined) continue; - const candidateTurn = excludedTurns[candidate.index]; - if (candidateTurn !== undefined && isFoldableHandoffTurn(candidateTurn)) - continue; - if ( - candidate.score < ANCHOR_SCORE_THRESHOLD || - anchorIndices.has(candidate.index) - ) - continue; - const closure = pairClosure( - candidate.index, - partnerIndex, - tailStart, - anchorIndices, - ); - if (closure.size > anchorBudget) continue; - for (const idx of closure) anchorIndices.add(idx); - anchorBudget -= closure.size; - } - - // Always keep the initiating task verbatim, outside the maxAnchorTurns - // cap. Losing the oldest user turn is how the agent forgets what it was - // asked to do; correctness outranks the size target here. Prior compacted - // summaries are not the initiating task — they get folded. - const initiatingIdx = firstUserTurnIndex(excludedTurns); - if (initiatingIdx >= 0) - addPairClosure(initiatingIdx, partnerIndex, tailStart, anchorIndices); - - for (const idx of [...anchorIndices]) { - const turn = excludedTurns[idx]; - if (turn !== undefined && isFoldableHandoffTurn(turn)) - anchorIndices.delete(idx); - } - - // Ascending original order keeps the concatenated [anchors, tail] - // sequence globally index-ordered, so every result still follows its call. - const sortedAnchorIndices = [...anchorIndices].sort((a, b) => a - b); - const anchorTurns = sortedAnchorIndices.flatMap((i) => { - const turn = excludedTurns[i]; - return turn === undefined ? [] : [turn]; - }); - // Summarized region: everything outside the tail that is not an anchor. - // Prior fold spines ride along so buildHandoffFold folds them (never - // stacked); on a repeat fold the live tail carried forward re-enters - // here already excerpted — summarized from its shortened text, never - // re-expanded raw. - const summarizedTurns = [ - ...excludedTurns.filter((_, i) => !anchorIndices.has(i)), - ...carriedSpines, - ]; + // Live fold is the thin spine plus the token-capped tail. File-edit + // pairs, errors, and the initiating task fold into the fat handoff + // rather than riding as unexcerpted live bodies. + const summarizedTurns = excludedTurns; // Keep-set covered everything foldable: nothing to replace. Do not // invent an empty summary — but still emit the excerpted live copies // selectTail already paid for (plus image-aged turns and their spill - // blobs). Returning the unexcerpted aged turns would keep the occupancy - // that armed the compact on a short-but-bulky first fold. + // blobs) and still stub superseded reads when the tail is the whole + // transcript. if (summarizedTurns.length === 0) { + const liveTurns = + tail.excerpted.size === 0 + ? aged.turns + : aged.turns.map((turn, idx) => tail.excerpted.get(idx) ?? turn); + const pathToReads = buildPathToReads(liveTurns, callIndex); + const supersededReads = supersededReadCallIds(pathToReads); + const output = + supersededReads.size === 0 + ? liveTurns + : liveTurns.map((t) => + stubSupersededReads(t, supersededReads, callIndex), + ); return { - output: - tail.excerpted.size === 0 - ? aged.turns - : aged.turns.map((turn, idx) => tail.excerpted.get(idx) ?? turn), + output, record: { strategy: this.name, version: this.version, parameters: { - keepRecentTurns: cfg.keepRecentTurns, compactionShape: shape, ...extraInstructionParameter(cfg), }, reason: "no compaction needed", decisions: { - summarizedTurnCount: 0, tailBudgetTokens: shape.tailBudgetTokens, tailTokenEstimate: tail.tailTokenEstimate, shortenedToolOutputs: tail.shortenedToolOutputs, agedImageCount: aged.agedImageCount, + supersededReadCount: supersededReads.size, }, }, ...(aged.blobs.length > 0 ? { blobs: aged.blobs } : {}), @@ -1176,10 +1011,7 @@ export function createPruningCompactor( // Path-dedup only among turns that survive. Supersession over the full // transcript would hollow a kept older read when the newer re-read is only // in the summary (CL-4374 review follow-up). - const pathToReads = buildPathToReads( - [...anchorTurns, ...tailTurns], - callIndex, - ); + const pathToReads = buildPathToReads(tailTurns, callIndex); const supersededReads = supersededReadCallIds(pathToReads); // Repeat folds update the prior summary instead of summarizing beside @@ -1194,11 +1026,7 @@ export function createPruningCompactor( let summary: string; let summarizeFallback: SummarizerFailureClass | undefined; const stubSummary = (): string => - buildTurnSummary( - summarizedTurns, - cfg.summaryMaxChars, - anchorTurns.length, - ); + buildTurnSummary(summarizedTurns, cfg.summaryMaxChars); try { summary = cfg.summarize !== undefined @@ -1215,7 +1043,6 @@ export function createPruningCompactor( strategy: this.name, version: this.version, parameters: { - keepRecentTurns: cfg.keepRecentTurns, compactionShape: shape, ...extraInstructionParameter(cfg), }, @@ -1241,7 +1068,6 @@ export function createPruningCompactor( strategy: this.name, version: this.version, parameters: { - keepRecentTurns: cfg.keepRecentTurns, compactionShape: shape, ...extraInstructionParameter(cfg), }, @@ -1270,7 +1096,6 @@ export function createPruningCompactor( strategy: this.name, version: this.version, parameters: { - keepRecentTurns: cfg.keepRecentTurns, compactionShape: shape, }, reason: "verify failed — keeping prior context", @@ -1327,20 +1152,16 @@ export function createPruningCompactor( excludedTurns[excludedTurns.length - 1]?.timestamp ?? Date.now(), }; - // Anchors and tail turns stay contentful except for path-dedup: when the - // same file was read successfully more than once among kept turns, older - // results become a one-line stub and the newest stays whole. Error results - // are never stubbed. SummarizedTurns lose content wholesale via the summary - // above. Anchors are already image-aged (outside the recent window). Tail - // turns keep live base64 so a just-pasted screenshot still reaches the model. + // Tail turns stay contentful except for path-dedup: when the same file + // was read successfully more than once among kept turns, older results + // become a one-line stub and the newest stays whole. Error results are + // never stubbed. SummarizedTurns lose content wholesale via the summary + // above. Tail turns keep live base64 so a just-pasted screenshot still + // reaches the model. const process = (t: ConversationTurn): ConversationTurn => stubSupersededReads(t, supersededReads, callIndex); const liveOutput = separateAdjacentUserTurns( - coalesceAdjacentTextTurns([ - summaryTurn, - ...anchorTurns.map(process), - ...tailTurns.map(process), - ]), + coalesceAdjacentTextTurns([summaryTurn, ...tailTurns.map(process)]), ); return { @@ -1349,20 +1170,19 @@ export function createPruningCompactor( strategy: this.name, version: this.version, parameters: { - keepRecentTurns: cfg.keepRecentTurns, summaryMaxChars: cfg.summaryMaxChars, maxAnchorTurns: cfg.maxAnchorTurns, compactionShape: shape, ...extraInstructionParameter(cfg), }, - reason: `compacted ${summarizedTurns.length} turns, anchored ${anchorTurns.length}, keeping ${tailTurns.length} tail${ + reason: `compacted ${summarizedTurns.length} turns, keeping ${tailTurns.length} tail${ summarizeFallback !== undefined ? ` (statistics-only stub: ${summarizeFallback})` : "" }`, decisions: { summarizedTurnCount: summarizedTurns.length, - anchorTurnCount: anchorTurns.length, + anchorTurnCount: 0, recentTurnCount: tailTurns.length, tailBudgetTokens: shape.tailBudgetTokens, tailTokenEstimate: tail.tailTokenEstimate, diff --git a/src/session/runtime-assembly.test.ts b/src/session/runtime-assembly.test.ts index fa7416a42..5906396b9 100644 --- a/src/session/runtime-assembly.test.ts +++ b/src/session/runtime-assembly.test.ts @@ -537,6 +537,8 @@ describe("createSessionPruningCompactor", () => { telemetry, onFolded: (info) => folds.push(info), isAborted: () => aborted, + // CL-9489: pin a one-token tail budget so this tiny fixture still folds. + compactionShape: { tailBudgetTokens: 1 }, }); const now = Date.now(); const many = Array.from({ length: 8 }, (_, i) => ({ diff --git a/src/session/runtime-assembly.ts b/src/session/runtime-assembly.ts index 4d5a7bb18..1cacc40aa 100644 --- a/src/session/runtime-assembly.ts +++ b/src/session/runtime-assembly.ts @@ -54,7 +54,6 @@ import type { Approval, GrantScope } from "../permission/types.js"; import type { ReasoningEffort } from "../provider/reasoning-effort.js"; import type { SubAgentProvider } from "../subagent/index.js"; import { - COMPACTOR_KEEP_RECENT_TURNS, DEFAULT_TAIL_COMPACTION_SHAPE, createPruningCompactor, type CompactionShape, @@ -428,9 +427,9 @@ export interface SessionPruningCompactorArgs { */ isAborted?: () => boolean; /** - * CL-9007 budgeted-tail shape override. Absent means the shared production + * CL-9489 budgeted-tail shape override. Absent means the shared production * default (DEFAULT_TAIL_COMPACTION_SHAPE); tests pin a tiny budget so small - * fixtures still fold the same region the old keep-window cut folded. + * fixtures still fold the same region a keep-window used to cut. */ compactionShape?: Partial; } @@ -440,11 +439,10 @@ export function createSessionPruningCompactor( args: SessionPruningCompactorArgs, ): Compactor { const compactor = createPruningCompactor({ - keepRecentTurns: COMPACTOR_KEEP_RECENT_TURNS, summaryMaxChars: SESSION_COMPACTOR_SUMMARY_MAX_CHARS, - // CL-9007 budgeted-tail shape: explicit defaults (same object the record - // carries under parameters.compactionShape). keepRecentTurns stays as the - // legacy floor only — the budget decides how far past it the tail extends. + // CL-9489 budgeted-tail shape: explicit defaults (same object the record + // carries under parameters.compactionShape). Zero recent turns stay whole + // because they are recent — the token budget is the only tail cap. compactionShape: { ...DEFAULT_TAIL_COMPACTION_SHAPE, ...args.compactionShape, diff --git a/src/subagent/nudge-director.test.ts b/src/subagent/nudge-director.test.ts index fdb82eaf2..dd10960c2 100644 --- a/src/subagent/nudge-director.test.ts +++ b/src/subagent/nudge-director.test.ts @@ -6,10 +6,7 @@ import type { ReactorState, } from "@intx/types/runtime"; import { createCorbitsRetryPolicy } from "../agent/retry-policy.js"; -import { - COMPACTOR_KEEP_RECENT_TURNS, - compactorNoOpFloor, -} from "../session/compactor.js"; +import { compactorNoOpFloor } from "../session/compactor.js"; import { SubAgentDirector } from "./nudge-director.js"; import type { AdmissionQueue } from "./admission.js"; import { createTestCapabilities } from "./director-test-harness.js"; @@ -22,14 +19,11 @@ import { const state = { turns: [] } as unknown as ReactorState; const longState = { - turns: Array.from( - { length: compactorNoOpFloor(COMPACTOR_KEEP_RECENT_TURNS) + 1 }, - () => ({ - role: "user", - content: [], - timestamp: 0, - }), - ), + turns: Array.from({ length: compactorNoOpFloor() + 1 }, () => ({ + role: "user", + content: [], + timestamp: 0, + })), } as unknown as ReactorState; // These tests are not about stall timing. With the default real clock, a diff --git a/src/telemetry/product-events.test.ts b/src/telemetry/product-events.test.ts index 9d8f75c67..bd163981c 100644 --- a/src/telemetry/product-events.test.ts +++ b/src/telemetry/product-events.test.ts @@ -669,6 +669,8 @@ test("compaction fires only when turns were actually folded away", async () => { const compactor = createSessionPruningCompactor({ summarize: async () => "summary", telemetry, + // CL-9489: pin a one-token tail budget so this tiny fixture still folds. + compactionShape: { tailBudgetTokens: 1 }, }); const shortHistory = [ diff --git a/src/tui/runner/exit.ts b/src/tui/runner/exit.ts index a1fd3b22e..38dad6205 100644 --- a/src/tui/runner/exit.ts +++ b/src/tui/runner/exit.ts @@ -360,6 +360,7 @@ export async function createRunLifecycle( { onTasksChanged: (tasks) => services.emitter.emit("tasks", tasks), onToolsActivate: (names) => activateAndCommitWire(names), + onFoldNonConverged: (notice) => state.systemNotice?.(notice), }, (message, fields) => tuiLogger.debug(message, fields), );