Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions packages/fold-core/src/Compaction/CompactionLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -231,7 +238,7 @@ export const makeCompactionService = (config: EnabledAutoCompactConfig): Compact
const compactionPlan: CompactionPlan = {
prompt,
summary,
replacesThroughSeq: lastReplaced.sourceSeq,
replacesThroughSeq,
tokensBefore: latestReportedContextTokens(visible) ?? 0,
}

Expand Down
84 changes: 84 additions & 0 deletions packages/fold-core/test/Projection/Projection.vi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
EventLog,
Ids,
layerInMemoryEventLog,
makeCompactionService,
messagesForAgent,
runtimeForAgent,
toolStateForAgent,
Expand All @@ -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 }))

Expand Down Expand Up @@ -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* () {
Expand Down
Loading