From 663dfb3337542f205938e6c2f736eece104bd91e Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Tue, 1 Sep 2026 15:41:37 -0700 Subject: [PATCH] fix: preserve complete tool batches during compaction HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a05dd9-433a-7e50-a626-1599b88411e0 --- .../src/Compaction/CompactionLayer.ts | 13 ++- .../test/Projection/Projection.vi.test.ts | 84 +++++++++++++++++++ 2 files changed, 94 insertions(+), 3 deletions(-) diff --git a/packages/fold-core/src/Compaction/CompactionLayer.ts b/packages/fold-core/src/Compaction/CompactionLayer.ts index f3451ec..9df6dd0 100644 --- a/packages/fold-core/src/Compaction/CompactionLayer.ts +++ b/packages/fold-core/src/Compaction/CompactionLayer.ts @@ -189,8 +189,15 @@ export const makeCompactionService = (config: EnabledAutoCompactConfig): Compact const toSummarize = conversation.slice(0, historyEnd) const turnPrefix = cut.isSplitTurn ? conversation.slice(cut.turnStartIndex, firstKeptIndex) : [] const discarded = conversation.slice(0, firstKeptIndex) - const lastReplaced = discarded[discarded.length - 1] - if (lastReplaced === undefined) return null + const firstDiscarded = discarded[0] + if (firstDiscarded === undefined) return null + // Projected tool results can be reordered into their assistant call order while retaining + // physical event-log sequences. The durable cutoff applies to physical sequence order, so it + // must cover every discarded message rather than the final message in the projected order. + const replacesThroughSeq = discarded.reduce( + (maximum, message) => Math.max(maximum, message.sourceSeq), + firstDiscarded.sourceSeq, + ) yield* Effect.annotateCurrentSpan({ trigger: input.trigger, @@ -231,7 +238,7 @@ export const makeCompactionService = (config: EnabledAutoCompactConfig): Compact const compactionPlan: CompactionPlan = { prompt, summary, - replacesThroughSeq: lastReplaced.sourceSeq, + replacesThroughSeq, tokensBefore: latestReportedContextTokens(visible) ?? 0, } diff --git a/packages/fold-core/test/Projection/Projection.vi.test.ts b/packages/fold-core/test/Projection/Projection.vi.test.ts index 980595e..ad2d81c 100644 --- a/packages/fold-core/test/Projection/Projection.vi.test.ts +++ b/packages/fold-core/test/Projection/Projection.vi.test.ts @@ -6,6 +6,7 @@ import { EventLog, Ids, layerInMemoryEventLog, + makeCompactionService, messagesForAgent, runtimeForAgent, toolStateForAgent, @@ -17,6 +18,7 @@ import { type UserMessageEncoded, } from '../../src/index' import { layerDeterministicRuntime } from '../TestLayers/DeterministicRuntime' +import { makeScriptedLanguageModel, textTurn } from '../TestLayers/ScriptedLanguageModel' const testLayer = Layer.mergeAll(layerInMemoryEventLog, layerDeterministicRuntime({ startMillis: 10_000 })) @@ -276,6 +278,88 @@ it.effect('projects messages with the latest leading system message and assistan }), ) +it.effect('compaction cutoff covers every reordered tool result in its discarded prefix', () => + Effect.gen(function* () { + const log = yield* EventLog + const ids = yield* Ids + const rootAgentId = yield* appendRoot() + const firstToolCallId = yield* toolCallId + const secondToolCallId = yield* toolCallId + + yield* log.append({ + _tag: 'user-message', + agentId: rootAgentId, + parentAgentId: null, + toolCallId: null, + messageId: yield* messageId, + message: userMessage('complete the tool batch'), + }) + yield* log.append({ + _tag: 'assistant-message', + agentId: rootAgentId, + parentAgentId: null, + toolCallId: null, + messageId: yield* messageId, + message: assistantWithToolCalls([firstToolCallId, secondToolCallId]), + finish: null, + }) + + // The second call finishes first, so its result is persisted before the first call's result. + yield* log.append({ + _tag: 'tool-result', + agentId: rootAgentId, + parentAgentId: null, + toolCallId: secondToolCallId, + messageId: yield* messageId, + message: toolMessage(secondToolCallId, 1), + }) + yield* log.append({ + _tag: 'tool-result', + agentId: rootAgentId, + parentAgentId: null, + toolCallId: firstToolCallId, + messageId: yield* messageId, + message: toolMessage(firstToolCallId, 0), + }) + const firstToolResultSeq = lastSeq(yield* readEntries) + + // Keep this later message so compaction discards the full preceding tool exchange. + yield* log.append({ + _tag: 'user-message', + agentId: rootAgentId, + parentAgentId: null, + toolCallId: null, + messageId: yield* messageId, + message: userMessage('continue after compaction'), + }) + + const entries = yield* readEntries + const scripted = yield* makeScriptedLanguageModel([textTurn('tool batch summary')]) + const plan = yield* makeCompactionService({ enabled: true, thresholdTokens: 1, keepRecentTokens: 1 }) + .plan({ agentId: rootAgentId, entries, model, trigger: 'threshold' }) + .pipe(Effect.provide(scripted.layer)) + if (plan === null) throw new Error('expected a compaction plan') + + // Projection puts the second result last, but its smaller physical sequence must not become + // the durable cutoff. Otherwise the first result survives after its assistant call is removed. + expect(plan.replacesThroughSeq).toBe(firstToolResultSeq) + + yield* log.append({ + _tag: 'compaction', + agentId: rootAgentId, + parentAgentId: null, + toolCallId: null, + compactionId: yield* ids.makeCompactionId, + summary: plan.summary, + replacesThroughSeq: plan.replacesThroughSeq, + tokensBefore: plan.tokensBefore, + }) + + const projected = messagesForAgent(yield* readEntries, rootAgentId) + expect(projected.map((message) => message._tag)).toEqual(['compaction-summary', 'user-message']) + }).pipe(Effect.provide(testLayer)), +) + it.effect('projects legacy forks through completed parent history plus child entries', () => Effect.gen(function* () { const result = yield* Effect.gen(function* () {