@tanstack/ai-compaction 0.0.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +151 -5
- package/dist/esm/index.d.ts +148 -0
- package/dist/esm/index.js +343 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/index.test.d.ts +1 -0
- package/package.json +50 -10
- package/src/index.test.ts +543 -0
- package/src/index.ts +618 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@tanstack/ai-compaction` — context-window compaction as a `chat()`
|
|
3
|
+
* middleware. `withCompaction({ maxTokens, strategy })` runs before each model
|
|
4
|
+
* call: when the working message set grows past `maxTokens`, the chosen
|
|
5
|
+
* `CompactionStrategy` rewrites the messages. Because it runs every call,
|
|
6
|
+
* compaction is incremental and rolling.
|
|
7
|
+
*
|
|
8
|
+
* Strategies are pluggable, mirroring `AgentLoopStrategy`. Three are built in:
|
|
9
|
+
* {@link evictOldest}, {@link summarizeOldest}, and {@link clearToolResults}.
|
|
10
|
+
* Write your own by passing any {@link CompactionStrategy}.
|
|
11
|
+
*
|
|
12
|
+
* The system prompt is never touched — `chat()` keeps it separate from
|
|
13
|
+
* `messages`.
|
|
14
|
+
*/
|
|
15
|
+
import { MetadataCapability, getMetadata } from '@tanstack/ai'
|
|
16
|
+
import type {
|
|
17
|
+
ChatMiddleware,
|
|
18
|
+
ChatMiddlewareContext,
|
|
19
|
+
ModelMessage,
|
|
20
|
+
} from '@tanstack/ai'
|
|
21
|
+
|
|
22
|
+
/** CUSTOM stream event: compaction is about to run. */
|
|
23
|
+
export const COMPACTION_STARTED_EVENT = 'compaction:started'
|
|
24
|
+
/** CUSTOM stream event: compaction result (counts and previews). */
|
|
25
|
+
export const COMPACTION_STATE_EVENT = 'compaction:state'
|
|
26
|
+
/** CUSTOM stream event: compaction finished. */
|
|
27
|
+
export const COMPACTION_ENDED_EVENT = 'compaction:ended'
|
|
28
|
+
|
|
29
|
+
export type CompactionStreamEventName =
|
|
30
|
+
| typeof COMPACTION_STARTED_EVENT
|
|
31
|
+
| typeof COMPACTION_STATE_EVENT
|
|
32
|
+
| typeof COMPACTION_ENDED_EVENT
|
|
33
|
+
|
|
34
|
+
const PREVIEW_CHARS = 4000
|
|
35
|
+
const MAX_PREVIEWS = 24
|
|
36
|
+
|
|
37
|
+
/** One message in a `compaction:state` preview list. */
|
|
38
|
+
export interface CompactionMessagePreview {
|
|
39
|
+
role: string
|
|
40
|
+
tokens: number
|
|
41
|
+
text: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Payload of {@link COMPACTION_STARTED_EVENT}. */
|
|
45
|
+
export interface CompactionStartedEventValue {
|
|
46
|
+
before: number
|
|
47
|
+
messagesBefore: number
|
|
48
|
+
reusedCheckpoint: boolean
|
|
49
|
+
maxTokens: number
|
|
50
|
+
strategyKey?: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Payload of {@link COMPACTION_STATE_EVENT}. */
|
|
54
|
+
export interface CompactionStateEventValue {
|
|
55
|
+
before: number
|
|
56
|
+
after: number
|
|
57
|
+
messagesBefore: number
|
|
58
|
+
messagesAfter: number
|
|
59
|
+
reusedCheckpoint: boolean
|
|
60
|
+
maxTokens: number
|
|
61
|
+
strategyKey?: string
|
|
62
|
+
/** Messages removed or rewritten. */
|
|
63
|
+
dropped?: Array<CompactionMessagePreview>
|
|
64
|
+
/** Messages the model will see after compaction. */
|
|
65
|
+
result?: Array<CompactionMessagePreview>
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Payload of {@link COMPACTION_ENDED_EVENT}. */
|
|
69
|
+
export interface CompactionEndedEventValue {
|
|
70
|
+
after: number
|
|
71
|
+
messagesAfter: number
|
|
72
|
+
reusedCheckpoint: boolean
|
|
73
|
+
maxTokens: number
|
|
74
|
+
durationMs: number
|
|
75
|
+
strategyKey?: string
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function emitCompactionStarted(
|
|
79
|
+
ctx: ChatMiddlewareContext,
|
|
80
|
+
value: CompactionStartedEventValue,
|
|
81
|
+
) {
|
|
82
|
+
ctx.emitCustomEvent(COMPACTION_STARTED_EVENT, value)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function emitCompactionState(
|
|
86
|
+
ctx: ChatMiddlewareContext,
|
|
87
|
+
value: CompactionStateEventValue,
|
|
88
|
+
) {
|
|
89
|
+
ctx.emitCustomEvent(COMPACTION_STATE_EVENT, value)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function emitCompactionEnded(
|
|
93
|
+
ctx: ChatMiddlewareContext,
|
|
94
|
+
value: CompactionEndedEventValue,
|
|
95
|
+
) {
|
|
96
|
+
ctx.emitCustomEvent(COMPACTION_ENDED_EVENT, value)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const strategyKeys = new WeakMap<CompactionStrategy, string>()
|
|
100
|
+
const CHECKPOINT_NAMESPACE = '@tanstack/ai-compaction'
|
|
101
|
+
|
|
102
|
+
interface CompactionCheckpoint {
|
|
103
|
+
schemaVersion: 1
|
|
104
|
+
sourceMessageCount: number
|
|
105
|
+
sourceHash: string
|
|
106
|
+
strategyKey: string
|
|
107
|
+
compactedMessages: Array<ModelMessage>
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function identifyStrategy(
|
|
111
|
+
strategy: CompactionStrategy,
|
|
112
|
+
key: string | undefined,
|
|
113
|
+
): CompactionStrategy {
|
|
114
|
+
if (key) strategyKeys.set(strategy, key)
|
|
115
|
+
return strategy
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function hashMessages(
|
|
119
|
+
messages: ReadonlyArray<ModelMessage>,
|
|
120
|
+
): Promise<string> {
|
|
121
|
+
const bytes = new TextEncoder().encode(JSON.stringify(messages))
|
|
122
|
+
const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes)
|
|
123
|
+
return Array.from(new Uint8Array(digest), (byte) =>
|
|
124
|
+
byte.toString(16).padStart(2, '0'),
|
|
125
|
+
).join('')
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isModelMessage(value: unknown): value is ModelMessage {
|
|
129
|
+
return (
|
|
130
|
+
typeof value === 'object' &&
|
|
131
|
+
value !== null &&
|
|
132
|
+
'role' in value &&
|
|
133
|
+
(value.role === 'user' ||
|
|
134
|
+
value.role === 'assistant' ||
|
|
135
|
+
value.role === 'tool') &&
|
|
136
|
+
'content' in value
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function isCompactionCheckpoint(value: unknown): value is CompactionCheckpoint {
|
|
141
|
+
return (
|
|
142
|
+
typeof value === 'object' &&
|
|
143
|
+
value !== null &&
|
|
144
|
+
'schemaVersion' in value &&
|
|
145
|
+
value.schemaVersion === 1 &&
|
|
146
|
+
'sourceMessageCount' in value &&
|
|
147
|
+
typeof value.sourceMessageCount === 'number' &&
|
|
148
|
+
Number.isInteger(value.sourceMessageCount) &&
|
|
149
|
+
value.sourceMessageCount >= 0 &&
|
|
150
|
+
'sourceHash' in value &&
|
|
151
|
+
typeof value.sourceHash === 'string' &&
|
|
152
|
+
'strategyKey' in value &&
|
|
153
|
+
typeof value.strategyKey === 'string' &&
|
|
154
|
+
'compactedMessages' in value &&
|
|
155
|
+
Array.isArray(value.compactedMessages) &&
|
|
156
|
+
value.compactedMessages.every(isModelMessage)
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function messagePreviewText(message: ModelMessage): string {
|
|
161
|
+
if (typeof message.content === 'string') return message.content
|
|
162
|
+
return JSON.stringify(message.content ?? '')
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function toMessagePreview(
|
|
166
|
+
message: ModelMessage,
|
|
167
|
+
estimate: (message: ModelMessage) => number,
|
|
168
|
+
): CompactionMessagePreview {
|
|
169
|
+
const text = messagePreviewText(message)
|
|
170
|
+
return {
|
|
171
|
+
role: message.role,
|
|
172
|
+
tokens: estimate(message),
|
|
173
|
+
text:
|
|
174
|
+
text.length > PREVIEW_CHARS ? `${text.slice(0, PREVIEW_CHARS)}…` : text,
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function previewList(
|
|
179
|
+
messages: ReadonlyArray<ModelMessage>,
|
|
180
|
+
estimate: (message: ModelMessage) => number,
|
|
181
|
+
): Array<CompactionMessagePreview> {
|
|
182
|
+
const mapped = messages.map((message) => toMessagePreview(message, estimate))
|
|
183
|
+
if (mapped.length <= MAX_PREVIEWS) return mapped
|
|
184
|
+
return mapped.slice(0, MAX_PREVIEWS)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function droppedMessages(
|
|
188
|
+
before: ReadonlyArray<ModelMessage>,
|
|
189
|
+
after: ReadonlyArray<ModelMessage>,
|
|
190
|
+
): Array<ModelMessage> {
|
|
191
|
+
const afterKeys = new Set(after.map((message) => JSON.stringify(message)))
|
|
192
|
+
return before.filter((message) => !afterKeys.has(JSON.stringify(message)))
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function compactionStateValue(args: {
|
|
196
|
+
before: number
|
|
197
|
+
after: number
|
|
198
|
+
messagesBefore: number
|
|
199
|
+
messagesAfter: number
|
|
200
|
+
reusedCheckpoint: boolean
|
|
201
|
+
maxTokens: number
|
|
202
|
+
strategyKey?: string
|
|
203
|
+
beforeMessages?: ReadonlyArray<ModelMessage>
|
|
204
|
+
afterMessages?: ReadonlyArray<ModelMessage>
|
|
205
|
+
estimate: (message: ModelMessage) => number
|
|
206
|
+
}): CompactionStateEventValue {
|
|
207
|
+
const value: CompactionStateEventValue = {
|
|
208
|
+
before: args.before,
|
|
209
|
+
after: args.after,
|
|
210
|
+
messagesBefore: args.messagesBefore,
|
|
211
|
+
messagesAfter: args.messagesAfter,
|
|
212
|
+
reusedCheckpoint: args.reusedCheckpoint,
|
|
213
|
+
maxTokens: args.maxTokens,
|
|
214
|
+
...(args.strategyKey ? { strategyKey: args.strategyKey } : {}),
|
|
215
|
+
}
|
|
216
|
+
if (args.afterMessages) {
|
|
217
|
+
value.result = previewList(args.afterMessages, args.estimate)
|
|
218
|
+
}
|
|
219
|
+
if (args.beforeMessages && args.afterMessages) {
|
|
220
|
+
value.dropped = previewList(
|
|
221
|
+
droppedMessages(args.beforeMessages, args.afterMessages),
|
|
222
|
+
args.estimate,
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
return value
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Rough token estimate for one message. Default: characters / 4. */
|
|
229
|
+
export function estimateMessageTokens(message: ModelMessage): number {
|
|
230
|
+
let text = messagePreviewText(message)
|
|
231
|
+
if (message.toolCalls?.length) text += JSON.stringify(message.toolCalls)
|
|
232
|
+
return Math.ceil(text.length / 4)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** What a {@link CompactionStrategy} receives alongside the messages. */
|
|
236
|
+
export interface CompactionContext {
|
|
237
|
+
/** The `maxTokens` budget from `withCompaction`. */
|
|
238
|
+
maxTokens: number
|
|
239
|
+
/** The shared token estimator (default {@link estimateMessageTokens}). */
|
|
240
|
+
estimate: (message: ModelMessage) => number
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Shrinks a message list. Called only when the estimate is over budget.
|
|
245
|
+
* Return the rewritten messages, or `null` to leave them unchanged.
|
|
246
|
+
*/
|
|
247
|
+
export type CompactionStrategy = (
|
|
248
|
+
messages: ReadonlyArray<ModelMessage>,
|
|
249
|
+
ctx: CompactionContext,
|
|
250
|
+
) => Array<ModelMessage> | null | Promise<Array<ModelMessage> | null>
|
|
251
|
+
|
|
252
|
+
/** Reported to `onCompact` after each compaction event. */
|
|
253
|
+
export interface CompactionInfo {
|
|
254
|
+
/** Estimated tokens before compaction. */
|
|
255
|
+
before: number
|
|
256
|
+
/** Estimated tokens after compaction. */
|
|
257
|
+
after: number
|
|
258
|
+
/** Message count before compaction. */
|
|
259
|
+
messagesBefore: number
|
|
260
|
+
/** Message count after compaction (unchanged for {@link clearToolResults}). */
|
|
261
|
+
messagesAfter: number
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export interface CompactionOptions {
|
|
265
|
+
/** Compact when estimated tokens across `messages` exceed this. */
|
|
266
|
+
maxTokens: number
|
|
267
|
+
/** How to shrink the messages. Default: {@link evictOldest}. */
|
|
268
|
+
strategy?: CompactionStrategy
|
|
269
|
+
/** Per-message token estimator. Default: {@link estimateMessageTokens}. */
|
|
270
|
+
estimateTokens?: (message: ModelMessage) => number
|
|
271
|
+
/**
|
|
272
|
+
* Stable identity for persisted checkpoints. Set this for custom strategies
|
|
273
|
+
* or estimators, and change it when their output can change.
|
|
274
|
+
*/
|
|
275
|
+
strategyKey?: string
|
|
276
|
+
/** Observe each compaction (logging, metrics). */
|
|
277
|
+
onCompact?: (info: CompactionInfo) => void
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const sum = (
|
|
281
|
+
messages: ReadonlyArray<ModelMessage>,
|
|
282
|
+
estimate: (m: ModelMessage) => number,
|
|
283
|
+
) => messages.reduce((total, m) => total + estimate(m), 0)
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Find the split point that keeps the most recent messages up to
|
|
287
|
+
* `keepRecentTokens`, then moves the cut forward past any leading tool result
|
|
288
|
+
* so the kept tail never starts with an orphan (its tool call would be dropped).
|
|
289
|
+
* Returns the index where the tail begins (head is `messages[0..cut)`).
|
|
290
|
+
*/
|
|
291
|
+
function splitAtRecent(
|
|
292
|
+
messages: ReadonlyArray<ModelMessage>,
|
|
293
|
+
estimate: (m: ModelMessage) => number,
|
|
294
|
+
keepRecentTokens: number,
|
|
295
|
+
): number {
|
|
296
|
+
let kept = 0
|
|
297
|
+
let cut = messages.length
|
|
298
|
+
while (cut > 0) {
|
|
299
|
+
const prev = messages[cut - 1]
|
|
300
|
+
if (!prev) break
|
|
301
|
+
const size = estimate(prev)
|
|
302
|
+
if (kept + size > keepRecentTokens) break
|
|
303
|
+
kept += size
|
|
304
|
+
cut--
|
|
305
|
+
}
|
|
306
|
+
// Always keep at least the last message.
|
|
307
|
+
if (cut >= messages.length) cut = messages.length - 1
|
|
308
|
+
while (cut < messages.length && messages[cut]?.role === 'tool') cut++
|
|
309
|
+
// Trailing tool results: skipping orphans would drop the whole tail (the
|
|
310
|
+
// normal agent-loop state). Keep those results and the message that owns them.
|
|
311
|
+
if (cut >= messages.length) {
|
|
312
|
+
cut = messages.length
|
|
313
|
+
while (cut > 0 && messages[cut - 1]?.role === 'tool') cut--
|
|
314
|
+
if (cut > 0) cut--
|
|
315
|
+
}
|
|
316
|
+
return cut
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Drop the oldest messages and replace them with a short marker. Cheapest
|
|
321
|
+
* strategy — no extra model call. This is the default.
|
|
322
|
+
*/
|
|
323
|
+
export function evictOldest(
|
|
324
|
+
options: {
|
|
325
|
+
/** Tokens of recent messages to keep verbatim. Default `floor(maxTokens/2)`. */
|
|
326
|
+
keepRecentTokens?: number
|
|
327
|
+
/** Build the marker that replaces the dropped head. */
|
|
328
|
+
marker?: (droppedCount: number) => string
|
|
329
|
+
} = {},
|
|
330
|
+
): CompactionStrategy {
|
|
331
|
+
const strategy: CompactionStrategy = (messages, ctx) => {
|
|
332
|
+
const keep = options.keepRecentTokens ?? Math.floor(ctx.maxTokens / 2)
|
|
333
|
+
const cut = splitAtRecent(messages, ctx.estimate, keep)
|
|
334
|
+
// Can't shrink past the recent window; raise keepRecentTokens or lower
|
|
335
|
+
// maxTokens if compaction never fires.
|
|
336
|
+
if (cut <= 0) return null
|
|
337
|
+
const marker =
|
|
338
|
+
options.marker?.(cut) ??
|
|
339
|
+
`[${cut} earlier message(s) omitted to save context.]`
|
|
340
|
+
return [{ role: 'user', content: marker }, ...messages.slice(cut)]
|
|
341
|
+
}
|
|
342
|
+
return identifyStrategy(
|
|
343
|
+
strategy,
|
|
344
|
+
options.marker
|
|
345
|
+
? undefined
|
|
346
|
+
: `evict-oldest:${options.keepRecentTokens ?? 'half'}`,
|
|
347
|
+
)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Drop the oldest messages and replace them with an LLM summary. Keeps the gist
|
|
352
|
+
* of old turns at the cost of one summarization call. Wire `summarize` to
|
|
353
|
+
* `summarize()` or any model call.
|
|
354
|
+
*/
|
|
355
|
+
export function summarizeOldest(options: {
|
|
356
|
+
summarize: (messages: Array<ModelMessage>) => Promise<string>
|
|
357
|
+
/** Tokens of recent messages to keep verbatim. Default `floor(maxTokens/2)`. */
|
|
358
|
+
keepRecentTokens?: number
|
|
359
|
+
/** Role of the injected summary message. Default `'assistant'`. */
|
|
360
|
+
summaryRole?: 'user' | 'assistant'
|
|
361
|
+
}): CompactionStrategy {
|
|
362
|
+
const strategy: CompactionStrategy = async (messages, ctx) => {
|
|
363
|
+
const keep = options.keepRecentTokens ?? Math.floor(ctx.maxTokens / 2)
|
|
364
|
+
const cut = splitAtRecent(messages, ctx.estimate, keep)
|
|
365
|
+
if (cut <= 0) return null
|
|
366
|
+
const summary = await options.summarize(messages.slice(0, cut))
|
|
367
|
+
return [
|
|
368
|
+
{
|
|
369
|
+
role: options.summaryRole ?? 'assistant',
|
|
370
|
+
content: `<untrusted-conversation-summary>\n${summary}\n</untrusted-conversation-summary>`,
|
|
371
|
+
},
|
|
372
|
+
...messages.slice(cut),
|
|
373
|
+
]
|
|
374
|
+
}
|
|
375
|
+
return identifyStrategy(
|
|
376
|
+
strategy,
|
|
377
|
+
`summarize-oldest:${options.keepRecentTokens ?? 'half'}:${options.summaryRole ?? 'assistant'}`,
|
|
378
|
+
)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Replace the content of old tool-result messages with a stub, keeping every
|
|
383
|
+
* message and its tool-call pairing in place. Best for agent loops where tool
|
|
384
|
+
* output (file reads, command output) dominates the token count — it clears the
|
|
385
|
+
* bulk without disturbing the conversation shape. No extra model call.
|
|
386
|
+
*/
|
|
387
|
+
export function clearToolResults(
|
|
388
|
+
options: {
|
|
389
|
+
/** Number of most-recent tool results to keep verbatim. Default `3`. */
|
|
390
|
+
keepRecentToolResults?: number
|
|
391
|
+
/** Text that replaces a cleared tool result. */
|
|
392
|
+
stub?: string
|
|
393
|
+
} = {},
|
|
394
|
+
): CompactionStrategy {
|
|
395
|
+
const keepN = options.keepRecentToolResults ?? 3
|
|
396
|
+
const stub = options.stub ?? '[tool output cleared to save context]'
|
|
397
|
+
const strategy: CompactionStrategy = (messages) => {
|
|
398
|
+
const toolIndexes: Array<number> = []
|
|
399
|
+
messages.forEach((m, i) => {
|
|
400
|
+
if (m.role === 'tool') toolIndexes.push(i)
|
|
401
|
+
})
|
|
402
|
+
if (toolIndexes.length <= keepN) return null
|
|
403
|
+
const clearBefore = toolIndexes[toolIndexes.length - keepN] ?? 0
|
|
404
|
+
let changed = false
|
|
405
|
+
const next = messages.map((m, i) => {
|
|
406
|
+
if (m.role === 'tool' && i < clearBefore && m.content !== stub) {
|
|
407
|
+
changed = true
|
|
408
|
+
return { ...m, content: stub }
|
|
409
|
+
}
|
|
410
|
+
return m
|
|
411
|
+
})
|
|
412
|
+
return changed ? next : null
|
|
413
|
+
}
|
|
414
|
+
return identifyStrategy(strategy, `clear-tool-results:${keepN}:${stub}`)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Run several strategies in order, escalating: stop as soon as the running
|
|
419
|
+
* estimate is back under `maxTokens`. Put the cheap, targeted strategy first
|
|
420
|
+
* (for example {@link clearToolResults}) and a broad fallback last (for example
|
|
421
|
+
* {@link evictOldest}) — the fallback only runs when clearing was not enough.
|
|
422
|
+
* A strategy that returns `null` (no change) is skipped and the next one runs.
|
|
423
|
+
*
|
|
424
|
+
* @example
|
|
425
|
+
* ```ts
|
|
426
|
+
* withCompaction({
|
|
427
|
+
* maxTokens: 100_000,
|
|
428
|
+
* strategy: composeStrategies(clearToolResults(), evictOldest()),
|
|
429
|
+
* })
|
|
430
|
+
* ```
|
|
431
|
+
*/
|
|
432
|
+
export function composeStrategies(
|
|
433
|
+
...strategies: Array<CompactionStrategy>
|
|
434
|
+
): CompactionStrategy {
|
|
435
|
+
const strategy: CompactionStrategy = async (messages, ctx) => {
|
|
436
|
+
let current: ReadonlyArray<ModelMessage> = messages
|
|
437
|
+
let result: Array<ModelMessage> | null = null
|
|
438
|
+
for (const itemStrategy of strategies) {
|
|
439
|
+
if (sum(current, ctx.estimate) <= ctx.maxTokens) break
|
|
440
|
+
const out = await itemStrategy(current, ctx)
|
|
441
|
+
if (out) {
|
|
442
|
+
current = out
|
|
443
|
+
result = out
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return result
|
|
447
|
+
}
|
|
448
|
+
const keys = strategies.map((item) => strategyKeys.get(item))
|
|
449
|
+
return identifyStrategy(
|
|
450
|
+
strategy,
|
|
451
|
+
keys.every((key) => key !== undefined) ? keys.join('|') : undefined,
|
|
452
|
+
)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Context-compaction middleware. Add to `chat({ middleware: [...] })`.
|
|
457
|
+
*
|
|
458
|
+
* @example
|
|
459
|
+
* ```ts
|
|
460
|
+
* chat({
|
|
461
|
+
* adapter,
|
|
462
|
+
* messages,
|
|
463
|
+
* middleware: [withCompaction({ maxTokens: 100_000 })], // evictOldest by default
|
|
464
|
+
* })
|
|
465
|
+
* ```
|
|
466
|
+
*/
|
|
467
|
+
export function withCompaction(options: CompactionOptions): ChatMiddleware {
|
|
468
|
+
const estimate = options.estimateTokens ?? estimateMessageTokens
|
|
469
|
+
const strategy = options.strategy ?? evictOldest()
|
|
470
|
+
const strategyKey =
|
|
471
|
+
options.strategyKey ??
|
|
472
|
+
(options.estimateTokens ? undefined : strategyKeys.get(strategy))
|
|
473
|
+
const checkpointStrategyKey = strategyKey
|
|
474
|
+
? `${strategyKey}:maxTokens=${options.maxTokens}`
|
|
475
|
+
: undefined
|
|
476
|
+
|
|
477
|
+
return {
|
|
478
|
+
name: 'compaction',
|
|
479
|
+
optionalRequires: [MetadataCapability],
|
|
480
|
+
async onConfig(ctx, config) {
|
|
481
|
+
// init is discarded by the engine rebuild and can run before persistence
|
|
482
|
+
// hydrates the thread. Compact only on model-bound phases.
|
|
483
|
+
if (ctx.phase === 'init') return
|
|
484
|
+
|
|
485
|
+
const startedAt = Date.now()
|
|
486
|
+
const { messages } = config
|
|
487
|
+
const inputMessages = config.providerMessages ?? messages
|
|
488
|
+
const metadata = getMetadata(ctx, { optional: true })
|
|
489
|
+
let workingMessages = inputMessages
|
|
490
|
+
let reusedCheckpoint = false
|
|
491
|
+
|
|
492
|
+
if (metadata && checkpointStrategyKey && inputMessages === messages) {
|
|
493
|
+
const stored = await metadata.get(CHECKPOINT_NAMESPACE, ctx.threadId)
|
|
494
|
+
if (
|
|
495
|
+
isCompactionCheckpoint(stored) &&
|
|
496
|
+
stored.strategyKey === checkpointStrategyKey &&
|
|
497
|
+
stored.sourceMessageCount <= messages.length &&
|
|
498
|
+
stored.sourceHash ===
|
|
499
|
+
(await hashMessages(messages.slice(0, stored.sourceMessageCount)))
|
|
500
|
+
) {
|
|
501
|
+
workingMessages = [
|
|
502
|
+
...stored.compactedMessages,
|
|
503
|
+
...messages.slice(stored.sourceMessageCount),
|
|
504
|
+
]
|
|
505
|
+
reusedCheckpoint = true
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const before = sum(workingMessages, estimate)
|
|
510
|
+
const startedValue: CompactionStartedEventValue = {
|
|
511
|
+
before,
|
|
512
|
+
messagesBefore: workingMessages.length,
|
|
513
|
+
reusedCheckpoint,
|
|
514
|
+
maxTokens: options.maxTokens,
|
|
515
|
+
...(checkpointStrategyKey
|
|
516
|
+
? { strategyKey: checkpointStrategyKey }
|
|
517
|
+
: {}),
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (before <= options.maxTokens) {
|
|
521
|
+
if (reusedCheckpoint) {
|
|
522
|
+
emitCompactionStarted(ctx, startedValue)
|
|
523
|
+
const stateValue = compactionStateValue({
|
|
524
|
+
before,
|
|
525
|
+
after: before,
|
|
526
|
+
messagesBefore: workingMessages.length,
|
|
527
|
+
messagesAfter: workingMessages.length,
|
|
528
|
+
reusedCheckpoint: true,
|
|
529
|
+
maxTokens: options.maxTokens,
|
|
530
|
+
strategyKey: checkpointStrategyKey,
|
|
531
|
+
afterMessages: workingMessages,
|
|
532
|
+
estimate,
|
|
533
|
+
})
|
|
534
|
+
emitCompactionState(ctx, stateValue)
|
|
535
|
+
emitCompactionEnded(ctx, {
|
|
536
|
+
after: before,
|
|
537
|
+
messagesAfter: workingMessages.length,
|
|
538
|
+
reusedCheckpoint: true,
|
|
539
|
+
maxTokens: options.maxTokens,
|
|
540
|
+
durationMs: Date.now() - startedAt,
|
|
541
|
+
...(checkpointStrategyKey
|
|
542
|
+
? { strategyKey: checkpointStrategyKey }
|
|
543
|
+
: {}),
|
|
544
|
+
})
|
|
545
|
+
return { providerMessages: workingMessages }
|
|
546
|
+
}
|
|
547
|
+
return
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
emitCompactionStarted(ctx, startedValue)
|
|
551
|
+
const next = await strategy(workingMessages, {
|
|
552
|
+
maxTokens: options.maxTokens,
|
|
553
|
+
estimate,
|
|
554
|
+
})
|
|
555
|
+
if (!next || next === workingMessages) {
|
|
556
|
+
emitCompactionEnded(ctx, {
|
|
557
|
+
after: before,
|
|
558
|
+
messagesAfter: workingMessages.length,
|
|
559
|
+
reusedCheckpoint,
|
|
560
|
+
maxTokens: options.maxTokens,
|
|
561
|
+
durationMs: Date.now() - startedAt,
|
|
562
|
+
...(checkpointStrategyKey
|
|
563
|
+
? { strategyKey: checkpointStrategyKey }
|
|
564
|
+
: {}),
|
|
565
|
+
})
|
|
566
|
+
if (reusedCheckpoint) {
|
|
567
|
+
return { providerMessages: workingMessages }
|
|
568
|
+
}
|
|
569
|
+
return
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const info = {
|
|
573
|
+
before,
|
|
574
|
+
after: sum(next, estimate),
|
|
575
|
+
messagesBefore: workingMessages.length,
|
|
576
|
+
messagesAfter: next.length,
|
|
577
|
+
}
|
|
578
|
+
options.onCompact?.(info)
|
|
579
|
+
emitCompactionState(
|
|
580
|
+
ctx,
|
|
581
|
+
compactionStateValue({
|
|
582
|
+
...info,
|
|
583
|
+
reusedCheckpoint,
|
|
584
|
+
maxTokens: options.maxTokens,
|
|
585
|
+
strategyKey: checkpointStrategyKey,
|
|
586
|
+
beforeMessages: workingMessages,
|
|
587
|
+
afterMessages: next,
|
|
588
|
+
estimate,
|
|
589
|
+
}),
|
|
590
|
+
)
|
|
591
|
+
emitCompactionEnded(ctx, {
|
|
592
|
+
after: info.after,
|
|
593
|
+
messagesAfter: info.messagesAfter,
|
|
594
|
+
reusedCheckpoint,
|
|
595
|
+
maxTokens: options.maxTokens,
|
|
596
|
+
durationMs: Date.now() - startedAt,
|
|
597
|
+
...(checkpointStrategyKey
|
|
598
|
+
? { strategyKey: checkpointStrategyKey }
|
|
599
|
+
: {}),
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
if (metadata && checkpointStrategyKey && inputMessages === messages) {
|
|
603
|
+
const checkpoint: CompactionCheckpoint = {
|
|
604
|
+
schemaVersion: 1,
|
|
605
|
+
sourceMessageCount: messages.length,
|
|
606
|
+
sourceHash: await hashMessages(messages),
|
|
607
|
+
strategyKey: checkpointStrategyKey,
|
|
608
|
+
compactedMessages: next,
|
|
609
|
+
}
|
|
610
|
+
if (!ctx.signal?.aborted) {
|
|
611
|
+
await metadata.set(CHECKPOINT_NAMESPACE, ctx.threadId, checkpoint)
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
return { providerMessages: next }
|
|
616
|
+
},
|
|
617
|
+
}
|
|
618
|
+
}
|