@animalabs/context-manager 0.2.0 → 0.4.0
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/dist/src/context-manager.d.ts +69 -2
- package/dist/src/context-manager.d.ts.map +1 -1
- package/dist/src/context-manager.js +128 -10
- package/dist/src/context-manager.js.map +1 -1
- package/dist/src/strategies/autobiographical.d.ts +217 -3
- package/dist/src/strategies/autobiographical.d.ts.map +1 -1
- package/dist/src/strategies/autobiographical.js +813 -77
- package/dist/src/strategies/autobiographical.js.map +1 -1
- package/dist/src/types/index.d.ts +2 -2
- package/dist/src/types/index.d.ts.map +1 -1
- package/dist/src/types/index.js +1 -1
- package/dist/src/types/index.js.map +1 -1
- package/dist/src/types/strategy.d.ts +208 -0
- package/dist/src/types/strategy.d.ts.map +1 -1
- package/dist/src/types/strategy.js +21 -0
- package/dist/src/types/strategy.js.map +1 -1
- package/dist/test/non-blocking-compile.test.d.ts +22 -0
- package/dist/test/non-blocking-compile.test.d.ts.map +1 -0
- package/dist/test/non-blocking-compile.test.js +114 -0
- package/dist/test/non-blocking-compile.test.js.map +1 -0
- package/dist/test/pins.test.d.ts +10 -0
- package/dist/test/pins.test.d.ts.map +1 -0
- package/dist/test/pins.test.js +164 -0
- package/dist/test/pins.test.js.map +1 -0
- package/dist/test/recall-positioning.test.d.ts +16 -0
- package/dist/test/recall-positioning.test.d.ts.map +1 -0
- package/dist/test/recall-positioning.test.js +181 -0
- package/dist/test/recall-positioning.test.js.map +1 -0
- package/dist/test/recent-window-eviction.test.d.ts +19 -0
- package/dist/test/recent-window-eviction.test.d.ts.map +1 -0
- package/dist/test/recent-window-eviction.test.js +206 -0
- package/dist/test/recent-window-eviction.test.js.map +1 -0
- package/dist/test/search.test.d.ts +10 -0
- package/dist/test/search.test.d.ts.map +1 -0
- package/dist/test/search.test.js +141 -0
- package/dist/test/search.test.js.map +1 -0
- package/dist/test/speculation-cap.test.d.ts +17 -0
- package/dist/test/speculation-cap.test.d.ts.map +1 -0
- package/dist/test/speculation-cap.test.js +157 -0
- package/dist/test/speculation-cap.test.js.map +1 -0
- package/dist/test/strategy-persistence.test.d.ts +15 -0
- package/dist/test/strategy-persistence.test.d.ts.map +1 -0
- package/dist/test/strategy-persistence.test.js +244 -0
- package/dist/test/strategy-persistence.test.js.map +1 -0
- package/dist/test/tool-pruning.test.d.ts +10 -0
- package/dist/test/tool-pruning.test.d.ts.map +1 -0
- package/dist/test/tool-pruning.test.js +140 -0
- package/dist/test/tool-pruning.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/context-manager.ts +142 -10
- package/src/strategies/autobiographical.ts +872 -81
- package/src/types/index.ts +14 -1
- package/src/types/strategy.ts +223 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { JsStore } from '@animalabs/chronicle';
|
|
1
2
|
import type { Membrane, NormalizedRequest, ContentBlock, CompleteOptions } from '@animalabs/membrane';
|
|
2
3
|
import { NativeFormatter } from '@animalabs/membrane';
|
|
3
4
|
import type {
|
|
@@ -13,6 +14,9 @@ import type {
|
|
|
13
14
|
AutobiographicalConfig,
|
|
14
15
|
SummaryLevel,
|
|
15
16
|
SummaryEntry,
|
|
17
|
+
ProtectedRange,
|
|
18
|
+
SearchQuery,
|
|
19
|
+
SearchResult,
|
|
16
20
|
} from '../types/index.js';
|
|
17
21
|
import { DEFAULT_AUTOBIOGRAPHICAL_CONFIG } from '../types/index.js';
|
|
18
22
|
|
|
@@ -86,6 +90,20 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
86
90
|
/** Cached result of getHeadWindowStartIndex to avoid repeated linear scans. */
|
|
87
91
|
private _cachedHeadStartIndex: { id: string | null; msgCount: number; result: number } | null = null;
|
|
88
92
|
|
|
93
|
+
/** Chronicle store for persistent state. Set in `initialize()`. */
|
|
94
|
+
protected store: JsStore | null = null;
|
|
95
|
+
/** Namespace for state-id scoping. Set in `initialize()`. */
|
|
96
|
+
protected ns: string = '';
|
|
97
|
+
protected get summariesStateId(): string { return `${this.ns}/autobio:summaries`; }
|
|
98
|
+
protected get counterStateId(): string { return `${this.ns}/autobio:counter`; }
|
|
99
|
+
protected get mergeQueueStateId(): string { return `${this.ns}/autobio:mergeQueue`; }
|
|
100
|
+
protected get pinsStateId(): string { return `${this.ns}/autobio:pins`; }
|
|
101
|
+
|
|
102
|
+
/** Protected ranges (pins + documents). Loaded from chronicle in initialize. */
|
|
103
|
+
protected pins: ProtectedRange[] = [];
|
|
104
|
+
/** Monotonically increasing counter for pin ids. Persisted as part of the pins snapshot. */
|
|
105
|
+
protected pinIdCounter = 0;
|
|
106
|
+
|
|
89
107
|
constructor(config: Partial<AutobiographicalConfig> = {}) {
|
|
90
108
|
this.config = { ...DEFAULT_AUTOBIOGRAPHICAL_CONFIG, ...config };
|
|
91
109
|
// Hierarchical is on by default; set hierarchical: false to use legacy single-level
|
|
@@ -100,8 +118,15 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
100
118
|
}
|
|
101
119
|
|
|
102
120
|
async initialize(ctx: StrategyContext): Promise<void> {
|
|
121
|
+
// Bind to the chronicle store + namespace for persistent strategy state.
|
|
122
|
+
this.store = ctx.store;
|
|
123
|
+
this.ns = ctx.namespace;
|
|
124
|
+
this.registerStates();
|
|
125
|
+
this.loadPersistedState();
|
|
126
|
+
|
|
103
127
|
// Restore headWindowStartId from last topic transition message
|
|
104
128
|
const messages = ctx.messageStore.getAll();
|
|
129
|
+
this.headWindowStartId = null;
|
|
105
130
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
106
131
|
if (this.isTopicTransitionMessage(messages[i])) {
|
|
107
132
|
this.headWindowStartId = messages[i].id;
|
|
@@ -111,6 +136,296 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
111
136
|
this.rebuildChunks(ctx.messageStore);
|
|
112
137
|
}
|
|
113
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Register the three Chronicle state slots this strategy uses.
|
|
141
|
+
* Idempotent — chronicle throws if a state is already registered, which we
|
|
142
|
+
* swallow (the existing slot is what we want).
|
|
143
|
+
*/
|
|
144
|
+
protected registerStates(): void {
|
|
145
|
+
if (!this.store) return;
|
|
146
|
+
try {
|
|
147
|
+
this.store.registerState({
|
|
148
|
+
id: this.summariesStateId,
|
|
149
|
+
strategy: 'append_log',
|
|
150
|
+
deltaSnapshotEvery: 50,
|
|
151
|
+
fullSnapshotEvery: 10,
|
|
152
|
+
});
|
|
153
|
+
} catch { /* already registered */ }
|
|
154
|
+
try {
|
|
155
|
+
this.store.registerState({
|
|
156
|
+
id: this.counterStateId,
|
|
157
|
+
strategy: 'snapshot',
|
|
158
|
+
});
|
|
159
|
+
} catch { /* already registered */ }
|
|
160
|
+
try {
|
|
161
|
+
this.store.registerState({
|
|
162
|
+
id: this.mergeQueueStateId,
|
|
163
|
+
strategy: 'snapshot',
|
|
164
|
+
});
|
|
165
|
+
} catch { /* already registered */ }
|
|
166
|
+
try {
|
|
167
|
+
this.store.registerState({
|
|
168
|
+
id: this.pinsStateId,
|
|
169
|
+
strategy: 'snapshot',
|
|
170
|
+
});
|
|
171
|
+
} catch { /* already registered */ }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Load summaries, counter, and pending merges from chronicle into the
|
|
176
|
+
* in-memory mirrors. Called on every (re)initialize so branch switches
|
|
177
|
+
* pick up the new branch's state.
|
|
178
|
+
*/
|
|
179
|
+
protected loadPersistedState(): void {
|
|
180
|
+
if (!this.store) {
|
|
181
|
+
this.summaries = [];
|
|
182
|
+
this.summaryIdCounter = 0;
|
|
183
|
+
this.mergeQueue = [];
|
|
184
|
+
this.pins = [];
|
|
185
|
+
this.pinIdCounter = 0;
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const summaries = this.store.getStateJson(this.summariesStateId);
|
|
189
|
+
this.summaries = Array.isArray(summaries) ? (summaries as SummaryEntry[]) : [];
|
|
190
|
+
|
|
191
|
+
const counter = this.store.getStateJson(this.counterStateId);
|
|
192
|
+
this.summaryIdCounter = typeof counter === 'number' ? counter : 0;
|
|
193
|
+
|
|
194
|
+
const queue = this.store.getStateJson(this.mergeQueueStateId);
|
|
195
|
+
this.mergeQueue = Array.isArray(queue)
|
|
196
|
+
? (queue as Array<{ level: SummaryLevel; sourceIds: string[] }>)
|
|
197
|
+
: [];
|
|
198
|
+
|
|
199
|
+
const pinsState = this.store.getStateJson(this.pinsStateId);
|
|
200
|
+
if (pinsState && typeof pinsState === 'object' && Array.isArray((pinsState as { pins?: unknown }).pins)) {
|
|
201
|
+
const ps = pinsState as { pins: ProtectedRange[]; counter?: number };
|
|
202
|
+
this.pins = ps.pins;
|
|
203
|
+
this.pinIdCounter = typeof ps.counter === 'number' ? ps.counter : ps.pins.length;
|
|
204
|
+
} else {
|
|
205
|
+
this.pins = [];
|
|
206
|
+
this.pinIdCounter = 0;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Persist the current pins + counter as a single snapshot. */
|
|
211
|
+
protected persistPins(): void {
|
|
212
|
+
this.store?.setStateJson(this.pinsStateId, {
|
|
213
|
+
pins: this.pins,
|
|
214
|
+
counter: this.pinIdCounter,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ============================================================================
|
|
219
|
+
// Pins / documents (protected ranges)
|
|
220
|
+
// ============================================================================
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Pin a range of messages so they aren't compressed and render raw at
|
|
224
|
+
* their original position. Returns the pin id.
|
|
225
|
+
*/
|
|
226
|
+
pinRange(firstMessageId: string, lastMessageId: string, opts?: { name?: string }): string {
|
|
227
|
+
const id = `pin-${this.pinIdCounter++}`;
|
|
228
|
+
this.pins.push({
|
|
229
|
+
id,
|
|
230
|
+
firstMessageId,
|
|
231
|
+
lastMessageId,
|
|
232
|
+
kind: 'pin',
|
|
233
|
+
name: opts?.name,
|
|
234
|
+
created: Date.now(),
|
|
235
|
+
});
|
|
236
|
+
this.persistPins();
|
|
237
|
+
return id;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Mark a single message as a "document" — semantically a body of
|
|
242
|
+
* information the agent wants to retain in full. Functionally a
|
|
243
|
+
* single-message pin with `kind: 'document'`.
|
|
244
|
+
*/
|
|
245
|
+
markDocument(messageId: string, opts?: { name?: string }): string {
|
|
246
|
+
const id = `pin-${this.pinIdCounter++}`;
|
|
247
|
+
this.pins.push({
|
|
248
|
+
id,
|
|
249
|
+
firstMessageId: messageId,
|
|
250
|
+
lastMessageId: messageId,
|
|
251
|
+
kind: 'document',
|
|
252
|
+
name: opts?.name,
|
|
253
|
+
created: Date.now(),
|
|
254
|
+
});
|
|
255
|
+
this.persistPins();
|
|
256
|
+
return id;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Remove a pin or document mark by id. Returns true if removed. */
|
|
260
|
+
unpin(pinId: string): boolean {
|
|
261
|
+
const before = this.pins.length;
|
|
262
|
+
this.pins = this.pins.filter(p => p.id !== pinId);
|
|
263
|
+
if (this.pins.length < before) {
|
|
264
|
+
this.persistPins();
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Read-only list of all current pins. */
|
|
271
|
+
listPins(): ReadonlyArray<ProtectedRange> {
|
|
272
|
+
return this.pins;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ============================================================================
|
|
276
|
+
// Search (gap #7)
|
|
277
|
+
// ============================================================================
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Look up a single summary by id. Returns null if not found.
|
|
281
|
+
*/
|
|
282
|
+
getSummary(id: string): SummaryEntry | null {
|
|
283
|
+
return this.summaries.find(s => s.id === id) ?? null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Search summaries by substring or regex over their content.
|
|
288
|
+
*
|
|
289
|
+
* Result ordering: matches by descending hit count, then by descending
|
|
290
|
+
* `created` timestamp (newest first within the same hit count).
|
|
291
|
+
*
|
|
292
|
+
* Default behavior: only "live" (unmerged) summaries are searched. Set
|
|
293
|
+
* `includeMerged: true` to also include summaries that have been folded
|
|
294
|
+
* into a higher level.
|
|
295
|
+
*/
|
|
296
|
+
searchSummaries(query: SearchQuery): SearchResult[] {
|
|
297
|
+
const limit = query.limit ?? 50;
|
|
298
|
+
const includeMerged = query.includeMerged ?? false;
|
|
299
|
+
|
|
300
|
+
// Build the matcher
|
|
301
|
+
let matcher: ((content: string) => number) | null = null;
|
|
302
|
+
if (query.regex) {
|
|
303
|
+
const flags = query.regex.flags.includes('g') ? query.regex.flags : query.regex.flags + 'g';
|
|
304
|
+
const re = new RegExp(query.regex.source, flags);
|
|
305
|
+
matcher = (content: string) => {
|
|
306
|
+
const matches = content.match(re);
|
|
307
|
+
return matches ? matches.length : 0;
|
|
308
|
+
};
|
|
309
|
+
} else if (query.text) {
|
|
310
|
+
const needle = query.text.toLowerCase();
|
|
311
|
+
matcher = (content: string) => {
|
|
312
|
+
const hay = content.toLowerCase();
|
|
313
|
+
let count = 0;
|
|
314
|
+
let idx = 0;
|
|
315
|
+
while ((idx = hay.indexOf(needle, idx)) !== -1) {
|
|
316
|
+
count++;
|
|
317
|
+
idx += needle.length || 1;
|
|
318
|
+
}
|
|
319
|
+
return count;
|
|
320
|
+
};
|
|
321
|
+
} else {
|
|
322
|
+
// No pattern: every summary "matches" once
|
|
323
|
+
matcher = () => 1;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const levelsFilter = query.levels && query.levels.length > 0 ? new Set(query.levels) : null;
|
|
327
|
+
|
|
328
|
+
const results: SearchResult[] = [];
|
|
329
|
+
for (const s of this.summaries) {
|
|
330
|
+
if (!includeMerged && s.mergedInto) continue;
|
|
331
|
+
if (levelsFilter && !levelsFilter.has(s.level)) continue;
|
|
332
|
+
const matches = matcher(s.content);
|
|
333
|
+
if (matches > 0) {
|
|
334
|
+
results.push({ summary: s, matches });
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
results.sort((a, b) => {
|
|
339
|
+
if (b.matches !== a.matches) return b.matches - a.matches;
|
|
340
|
+
return b.summary.created - a.summary.created;
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
return results.slice(0, limit);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Whether a given message position is inside any protected range.
|
|
348
|
+
* Uses a position map (computed by caller) so callers can avoid
|
|
349
|
+
* repeated per-message lookups in tight loops.
|
|
350
|
+
*/
|
|
351
|
+
protected isPositionPinned(position: number, pinPositions: Set<number>): boolean {
|
|
352
|
+
return pinPositions.has(position);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Build a set of message-store positions covered by any pin. O(N pins · K range).
|
|
357
|
+
* Returns positions for which the message exists; orphan pins (deleted
|
|
358
|
+
* messages) are silently skipped.
|
|
359
|
+
*/
|
|
360
|
+
protected pinnedPositions(messages: StoredMessage[]): Set<number> {
|
|
361
|
+
if (this.pins.length === 0) return new Set();
|
|
362
|
+
const positionOf = new Map<string, number>();
|
|
363
|
+
for (let i = 0; i < messages.length; i++) {
|
|
364
|
+
positionOf.set(messages[i].id, i);
|
|
365
|
+
}
|
|
366
|
+
const out = new Set<number>();
|
|
367
|
+
for (const pin of this.pins) {
|
|
368
|
+
const first = positionOf.get(pin.firstMessageId);
|
|
369
|
+
const last = positionOf.get(pin.lastMessageId);
|
|
370
|
+
if (first === undefined || last === undefined) continue;
|
|
371
|
+
const lo = Math.min(first, last);
|
|
372
|
+
const hi = Math.max(first, last);
|
|
373
|
+
for (let i = lo; i <= hi; i++) out.add(i);
|
|
374
|
+
}
|
|
375
|
+
return out;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Append a summary to the in-memory list and to the chronicle AppendLog.
|
|
380
|
+
* Single point so subclasses inherit persistence.
|
|
381
|
+
*/
|
|
382
|
+
protected pushSummary(entry: SummaryEntry): void {
|
|
383
|
+
this.summaries.push(entry);
|
|
384
|
+
this.store?.appendToStateJson(this.summariesStateId, entry);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Mark a summary as merged into a higher-level summary, updating the
|
|
389
|
+
* chronicle copy at the same index. Index is the position in `this.summaries`.
|
|
390
|
+
*/
|
|
391
|
+
protected setMergedInto(entry: SummaryEntry, mergedIntoId: string): void {
|
|
392
|
+
entry.mergedInto = mergedIntoId;
|
|
393
|
+
if (!this.store) return;
|
|
394
|
+
const index = this.summaries.indexOf(entry);
|
|
395
|
+
if (index < 0) return;
|
|
396
|
+
this.store.editStateItem(
|
|
397
|
+
this.summariesStateId,
|
|
398
|
+
index,
|
|
399
|
+
Buffer.from(JSON.stringify(entry)),
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Allocate the next summary-id counter value and persist the new counter.
|
|
405
|
+
*/
|
|
406
|
+
protected nextSummaryIdCounter(): number {
|
|
407
|
+
const value = this.summaryIdCounter++;
|
|
408
|
+
this.store?.setStateJson(this.counterStateId, this.summaryIdCounter);
|
|
409
|
+
return value;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Push to the merge queue and persist the new queue snapshot.
|
|
414
|
+
*/
|
|
415
|
+
protected enqueueMerge(merge: { level: SummaryLevel; sourceIds: string[] }): void {
|
|
416
|
+
this.mergeQueue.push(merge);
|
|
417
|
+
this.store?.setStateJson(this.mergeQueueStateId, this.mergeQueue);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Pop from the merge queue and persist the new queue snapshot.
|
|
422
|
+
*/
|
|
423
|
+
protected dequeueMerge(): { level: SummaryLevel; sourceIds: string[] } | undefined {
|
|
424
|
+
const merge = this.mergeQueue.shift();
|
|
425
|
+
this.store?.setStateJson(this.mergeQueueStateId, this.mergeQueue);
|
|
426
|
+
return merge;
|
|
427
|
+
}
|
|
428
|
+
|
|
114
429
|
checkReadiness(): ReadinessState {
|
|
115
430
|
if (this.pendingCompression) {
|
|
116
431
|
return {
|
|
@@ -141,16 +456,75 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
141
456
|
async onNewMessage(message: StoredMessage, ctx: StrategyContext): Promise<void> {
|
|
142
457
|
this.rebuildChunks(ctx.messageStore);
|
|
143
458
|
|
|
144
|
-
// Auto-tick: fire compression in the background
|
|
145
|
-
// the
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
459
|
+
// Auto-tick: fire speculative compression in the background. After
|
|
460
|
+
// each tick completes, if the queue still has work AND we're under
|
|
461
|
+
// the speculation cap AND preflight allows, schedule another tick.
|
|
462
|
+
// This drains the queue ahead of need rather than one-chunk-per-
|
|
463
|
+
// user-turn (reactive). Combined with ContextManager.compile not
|
|
464
|
+
// awaiting pendingCompression, the agent's response and background
|
|
465
|
+
// compression run truly in parallel.
|
|
466
|
+
if (this.config.autoTickOnNewMessage && !this.pendingCompression) {
|
|
467
|
+
this.driveSpeculativeDrain(ctx);
|
|
151
468
|
}
|
|
152
469
|
}
|
|
153
470
|
|
|
471
|
+
/**
|
|
472
|
+
* Background-drain loop: keeps calling tick() while there's queued work,
|
|
473
|
+
* subject to the speculation cap and preflight hook. Recurses via
|
|
474
|
+
* `queueMicrotask` so one chunk's compression doesn't block the
|
|
475
|
+
* scheduling of the next.
|
|
476
|
+
*
|
|
477
|
+
* Stops if a tick fails to make progress (queue size unchanged) — guards
|
|
478
|
+
* against runaway recursion when tick is a no-op (e.g. no membrane
|
|
479
|
+
* configured, or a subclass override that doesn't process the queue).
|
|
480
|
+
*/
|
|
481
|
+
protected driveSpeculativeDrain(ctx: StrategyContext): void {
|
|
482
|
+
if (this.pendingCompression) return;
|
|
483
|
+
if (this.compressionQueue.length === 0 && this.mergeQueue.length === 0) return;
|
|
484
|
+
if (this.isAtSpeculativeCap()) return;
|
|
485
|
+
if (!this.shouldCompressPreflight()) return;
|
|
486
|
+
|
|
487
|
+
const beforeChunks = this.compressionQueue.length;
|
|
488
|
+
const beforeMerges = this.mergeQueue.length;
|
|
489
|
+
|
|
490
|
+
this.tick(ctx)
|
|
491
|
+
.then(() => {
|
|
492
|
+
const afterChunks = this.compressionQueue.length;
|
|
493
|
+
const afterMerges = this.mergeQueue.length;
|
|
494
|
+
// Made progress if either queue shrank.
|
|
495
|
+
const progressed = afterChunks < beforeChunks || afterMerges < beforeMerges;
|
|
496
|
+
if (!progressed) return;
|
|
497
|
+
// Recurse to drain more. queueMicrotask defers until the current
|
|
498
|
+
// task is done, letting other code (the agent's stream consumer)
|
|
499
|
+
// interleave.
|
|
500
|
+
queueMicrotask(() => this.driveSpeculativeDrain(ctx));
|
|
501
|
+
})
|
|
502
|
+
.catch((err) => {
|
|
503
|
+
console.error('AutobiographicalStrategy: speculative-drain error:', err);
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Whether the strategy's pending+queued L1 budget has reached the cap
|
|
509
|
+
* configured by `maxSpeculativeL1s`. If no cap is set, always false.
|
|
510
|
+
*/
|
|
511
|
+
protected isAtSpeculativeCap(): boolean {
|
|
512
|
+
const cap = this.config.maxSpeculativeL1s;
|
|
513
|
+
if (cap === undefined || cap < 0) return false;
|
|
514
|
+
const unmergedL1s = this.summaries.filter(s => s.level === 1 && !s.mergedInto).length;
|
|
515
|
+
return unmergedL1s + this.compressionQueue.length > cap;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Preflight hook for whether speculative compression should fire on
|
|
520
|
+
* `onNewMessage`. Returns true by default (current eager behavior).
|
|
521
|
+
* Subclasses can override for predictive scheduling — e.g. only fire
|
|
522
|
+
* when the live tail token count is approaching some threshold.
|
|
523
|
+
*/
|
|
524
|
+
protected shouldCompressPreflight(): boolean {
|
|
525
|
+
return true;
|
|
526
|
+
}
|
|
527
|
+
|
|
154
528
|
async tick(ctx: StrategyContext): Promise<void> {
|
|
155
529
|
if (this.pendingCompression) return;
|
|
156
530
|
|
|
@@ -179,12 +553,27 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
179
553
|
}
|
|
180
554
|
|
|
181
555
|
// Priority 2: Execute pending merges (hierarchical only)
|
|
556
|
+
//
|
|
557
|
+
// Peek at the head rather than dequeueing eagerly: dequeueMerge persists
|
|
558
|
+
// the shorter queue *before* the LLM call leaves the building, so a
|
|
559
|
+
// transient failure (429, network drop, timeout, executeMerge throw)
|
|
560
|
+
// would silently lose the merge from disk and the sources would sit at
|
|
561
|
+
// level N-1 with no mergedInto pointers forever. Commit the removal
|
|
562
|
+
// only after the merge succeeds; on failure, the queue keeps its entry
|
|
563
|
+
// and the next tick() retries it.
|
|
182
564
|
if (this.config.hierarchical && this.mergeQueue.length > 0) {
|
|
183
|
-
const merge = this.mergeQueue
|
|
565
|
+
const merge = this.mergeQueue[0]!;
|
|
184
566
|
this.pendingCompression = this.executeMerge(merge.level, merge.sourceIds, ctx);
|
|
185
567
|
|
|
186
568
|
try {
|
|
187
569
|
await this.pendingCompression;
|
|
570
|
+
// Success: drop from head and persist the shorter queue. We
|
|
571
|
+
// re-check that head is still our merge in case some future code
|
|
572
|
+
// path mutates the queue mid-await (today no other site does,
|
|
573
|
+
// but the assertion makes that invariant explicit).
|
|
574
|
+
if (this.mergeQueue[0] === merge) {
|
|
575
|
+
this.dequeueMerge();
|
|
576
|
+
}
|
|
188
577
|
} finally {
|
|
189
578
|
this.pendingCompression = null;
|
|
190
579
|
}
|
|
@@ -221,6 +610,56 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
221
610
|
};
|
|
222
611
|
}
|
|
223
612
|
|
|
613
|
+
/**
|
|
614
|
+
* Richer per-render stats: requires a message store view to compute the
|
|
615
|
+
* head + tail (recent window) sizes. Returns counts AND token sums per
|
|
616
|
+
* summary level, so observers can see "how much of the agent's context
|
|
617
|
+
* is in raw tail vs folded into L1/L2/L3."
|
|
618
|
+
*
|
|
619
|
+
* Useful for TUI / dashboards. The token sums use the strategy's own
|
|
620
|
+
* token estimates (which match what `select()` uses for budget math).
|
|
621
|
+
*/
|
|
622
|
+
getRenderStats(store: MessageStoreView): {
|
|
623
|
+
head: { messages: number; tokens: number };
|
|
624
|
+
tail: { messages: number; tokens: number };
|
|
625
|
+
summaries: {
|
|
626
|
+
l1: { count: number; tokens: number };
|
|
627
|
+
l2: { count: number; tokens: number };
|
|
628
|
+
l3: { count: number; tokens: number };
|
|
629
|
+
};
|
|
630
|
+
pending: { chunks: number; merges: number };
|
|
631
|
+
} {
|
|
632
|
+
const messages = store.getAll();
|
|
633
|
+
const headStart = this.getHeadWindowStartIndex(store);
|
|
634
|
+
const headEnd = this.getHeadWindowEnd(store);
|
|
635
|
+
const recentStart = this.getRecentWindowStart(store);
|
|
636
|
+
|
|
637
|
+
const sumTokens = (slice: StoredMessage[]): number =>
|
|
638
|
+
slice.reduce((acc, m) => acc + store.estimateTokens(m), 0);
|
|
639
|
+
|
|
640
|
+
const headMsgs = messages.slice(headStart, headEnd);
|
|
641
|
+
const tailMsgs = messages.slice(recentStart);
|
|
642
|
+
|
|
643
|
+
const live = (level: SummaryLevel) =>
|
|
644
|
+
this.summaries.filter(s => s.level === level && !s.mergedInto);
|
|
645
|
+
const sumLevelTokens = (level: SummaryLevel): number =>
|
|
646
|
+
live(level).reduce((acc, s) => acc + s.tokens, 0);
|
|
647
|
+
|
|
648
|
+
return {
|
|
649
|
+
head: { messages: headMsgs.length, tokens: sumTokens(headMsgs) },
|
|
650
|
+
tail: { messages: tailMsgs.length, tokens: sumTokens(tailMsgs) },
|
|
651
|
+
summaries: {
|
|
652
|
+
l1: { count: live(1).length, tokens: sumLevelTokens(1) },
|
|
653
|
+
l2: { count: live(2).length, tokens: sumLevelTokens(2) },
|
|
654
|
+
l3: { count: live(3).length, tokens: sumLevelTokens(3) },
|
|
655
|
+
},
|
|
656
|
+
pending: {
|
|
657
|
+
chunks: this.chunks.filter(c => !c.compressed).length,
|
|
658
|
+
merges: this.mergeQueue.length,
|
|
659
|
+
},
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
|
|
224
663
|
// ============================================================================
|
|
225
664
|
// Legacy (single-level) path
|
|
226
665
|
// ============================================================================
|
|
@@ -243,7 +682,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
243
682
|
const msg = messages[i];
|
|
244
683
|
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
245
684
|
const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
|
|
246
|
-
if (totalTokens + tokens
|
|
685
|
+
if (this.isOverBudget(totalTokens + tokens, maxTokens)) break;
|
|
247
686
|
|
|
248
687
|
entries.push({
|
|
249
688
|
index: entries.length,
|
|
@@ -278,17 +717,20 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
278
717
|
sourceRelation: 'derived',
|
|
279
718
|
};
|
|
280
719
|
|
|
720
|
+
// Synthesised summary turns must respect maxMessageTokens just like raw
|
|
721
|
+
// copies do — otherwise a runaway diary can starve recent messages.
|
|
722
|
+
const answerContent: ContentBlock[] = [{ type: 'text', text: chunk.diary }];
|
|
281
723
|
const answerEntry: ContextEntry = {
|
|
282
724
|
index: entries.length + 1,
|
|
283
725
|
participant: summaryParticipant,
|
|
284
|
-
content:
|
|
726
|
+
content: msgCap > 0 ? this.truncateContent(answerContent, msgCap) : answerContent,
|
|
285
727
|
sourceRelation: 'derived',
|
|
286
728
|
};
|
|
287
729
|
|
|
288
730
|
const pairTokens = this.estimateTokens(questionEntry.content) +
|
|
289
731
|
this.estimateTokens(answerEntry.content);
|
|
290
732
|
|
|
291
|
-
if (totalTokens + pairTokens
|
|
733
|
+
if (this.isOverBudget(totalTokens + pairTokens, maxTokens)) break;
|
|
292
734
|
|
|
293
735
|
entries.push(questionEntry);
|
|
294
736
|
entries.push(answerEntry);
|
|
@@ -298,7 +740,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
298
740
|
for (const msg of chunk.messages) {
|
|
299
741
|
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
300
742
|
const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
|
|
301
|
-
if (totalTokens + tokens
|
|
743
|
+
if (this.isOverBudget(totalTokens + tokens, maxTokens)) break;
|
|
302
744
|
|
|
303
745
|
entries.push({
|
|
304
746
|
index: entries.length,
|
|
@@ -321,7 +763,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
321
763
|
const msg = messages[i];
|
|
322
764
|
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
323
765
|
const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
|
|
324
|
-
if (totalTokens + tokens
|
|
766
|
+
if (this.isOverBudget(totalTokens + tokens, maxTokens)) break;
|
|
325
767
|
|
|
326
768
|
entries.push({
|
|
327
769
|
index: entries.length,
|
|
@@ -335,14 +777,60 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
335
777
|
|
|
336
778
|
// 3. Recent uncompressed messages (skip those already in head window)
|
|
337
779
|
const recentStart = Math.max(this.getRecentWindowStart(store), headEnd);
|
|
780
|
+
this.emitRecentNewestFirst(entries, store, messages, recentStart, msgCap, maxTokens, totalTokens);
|
|
338
781
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
782
|
+
this.trimOrphanedToolUse(entries);
|
|
783
|
+
this.pruneToolEntries(entries);
|
|
784
|
+
return entries;
|
|
785
|
+
}
|
|
343
786
|
|
|
344
|
-
|
|
787
|
+
/**
|
|
788
|
+
* Emit recent-window messages, evicting OLDEST-first when the budget is tight.
|
|
789
|
+
*
|
|
790
|
+
* The previous loop iterated `recentStart → messages.length` forward and broke
|
|
791
|
+
* on `totalTokens + tokens > maxTokens`. When the head/summary section eats most
|
|
792
|
+
* of the budget, the loop emits the oldest messages of the window and aborts
|
|
793
|
+
* before reaching the newest — exactly the messages an agent needs to act on.
|
|
794
|
+
* This helper picks newest-first within the budget, then emits the kept set in
|
|
795
|
+
* chronological order, dropping a leading orphan tool_result if its tool_use
|
|
796
|
+
* fell into the evicted older portion.
|
|
797
|
+
*/
|
|
798
|
+
protected emitRecentNewestFirst(
|
|
799
|
+
entries: ContextEntry[],
|
|
800
|
+
store: MessageStoreView,
|
|
801
|
+
messages: StoredMessage[],
|
|
802
|
+
recentStart: number,
|
|
803
|
+
msgCap: number,
|
|
804
|
+
maxTokens: number,
|
|
805
|
+
totalTokensBefore: number,
|
|
806
|
+
): void {
|
|
807
|
+
if (recentStart >= messages.length) return;
|
|
808
|
+
|
|
809
|
+
const accepted: number[] = [];
|
|
810
|
+
let acceptedTokens = 0;
|
|
811
|
+
for (let i = messages.length - 1; i >= recentStart; i--) {
|
|
812
|
+
const msg = messages[i];
|
|
813
|
+
const tokens = msgCap > 0
|
|
814
|
+
? Math.min(store.estimateTokens(msg), msgCap + 50)
|
|
815
|
+
: store.estimateTokens(msg);
|
|
816
|
+
if (this.isOverBudget(totalTokensBefore + acceptedTokens + tokens, maxTokens)) break;
|
|
817
|
+
accepted.push(i);
|
|
818
|
+
acceptedTokens += tokens;
|
|
819
|
+
}
|
|
820
|
+
accepted.reverse();
|
|
821
|
+
|
|
822
|
+
// Drop leading orphan tool_result(s): their matching tool_use was evicted.
|
|
823
|
+
while (
|
|
824
|
+
accepted.length > 0 &&
|
|
825
|
+
this.hasToolResult(messages[accepted[0]]) &&
|
|
826
|
+
!this.hasToolUse(messages[accepted[0]])
|
|
827
|
+
) {
|
|
828
|
+
accepted.shift();
|
|
829
|
+
}
|
|
345
830
|
|
|
831
|
+
for (const i of accepted) {
|
|
832
|
+
const msg = messages[i];
|
|
833
|
+
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
346
834
|
entries.push({
|
|
347
835
|
index: entries.length,
|
|
348
836
|
sourceMessageId: msg.id,
|
|
@@ -350,11 +838,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
350
838
|
participant: msg.participant,
|
|
351
839
|
content,
|
|
352
840
|
});
|
|
353
|
-
totalTokens += tokens;
|
|
354
841
|
}
|
|
355
|
-
|
|
356
|
-
this.trimOrphanedToolUse(entries);
|
|
357
|
-
return entries;
|
|
358
842
|
}
|
|
359
843
|
|
|
360
844
|
protected async compressChunkLegacy(chunk: Chunk, ctx: StrategyContext): Promise<void> {
|
|
@@ -385,7 +869,6 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
385
869
|
config: {
|
|
386
870
|
model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
|
|
387
871
|
maxTokens: 2000,
|
|
388
|
-
temperature: 0,
|
|
389
872
|
},
|
|
390
873
|
};
|
|
391
874
|
|
|
@@ -538,7 +1021,6 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
538
1021
|
config: {
|
|
539
1022
|
model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
|
|
540
1023
|
maxTokens: Math.round(targetTokens * 1.5),
|
|
541
|
-
temperature: 0,
|
|
542
1024
|
},
|
|
543
1025
|
};
|
|
544
1026
|
|
|
@@ -551,7 +1033,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
551
1033
|
|
|
552
1034
|
const messageIds = chunk.messages.map(m => m.id);
|
|
553
1035
|
const entry: SummaryEntry = {
|
|
554
|
-
id: `L1-${this.
|
|
1036
|
+
id: `L1-${this.nextSummaryIdCounter()}`,
|
|
555
1037
|
level: 1,
|
|
556
1038
|
content: summaryText,
|
|
557
1039
|
tokens: Math.ceil(summaryText.length / 4),
|
|
@@ -565,7 +1047,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
565
1047
|
phaseType: chunk.phaseType,
|
|
566
1048
|
};
|
|
567
1049
|
|
|
568
|
-
this.
|
|
1050
|
+
this.pushSummary(entry);
|
|
569
1051
|
chunk.compressed = true;
|
|
570
1052
|
chunk.summaryId = entry.id;
|
|
571
1053
|
this._compressionCount++;
|
|
@@ -580,25 +1062,43 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
580
1062
|
/**
|
|
581
1063
|
* Check if unmerged summary counts exceed the merge threshold.
|
|
582
1064
|
* Enqueues merge operations if so.
|
|
1065
|
+
*
|
|
1066
|
+
* Skips L1s/L2s that are already in a pending merge — without this guard,
|
|
1067
|
+
* each new summary above threshold re-enqueues a merge for the same
|
|
1068
|
+
* already-eligible siblings, producing N near-identical higher-level
|
|
1069
|
+
* summaries when the queue eventually drains.
|
|
583
1070
|
*/
|
|
584
1071
|
protected checkMergeThreshold(): void {
|
|
585
1072
|
const threshold = this.config.mergeThreshold ?? 6;
|
|
586
1073
|
|
|
1074
|
+
// IDs that are already part of a queued merge — exclude them from
|
|
1075
|
+
// eligibility so we don't re-enqueue.
|
|
1076
|
+
const queuedL1 = new Set<string>();
|
|
1077
|
+
const queuedL2 = new Set<string>();
|
|
1078
|
+
for (const m of this.mergeQueue) {
|
|
1079
|
+
const set = m.level === 2 ? queuedL1 : queuedL2;
|
|
1080
|
+
for (const id of m.sourceIds) set.add(id);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
587
1083
|
// Check L1 → L2
|
|
588
|
-
const unmergedL1 = this.summaries.filter(
|
|
1084
|
+
const unmergedL1 = this.summaries.filter(
|
|
1085
|
+
s => s.level === 1 && !s.mergedInto && !queuedL1.has(s.id),
|
|
1086
|
+
);
|
|
589
1087
|
if (unmergedL1.length >= threshold) {
|
|
590
1088
|
const toMerge = unmergedL1.slice(0, threshold);
|
|
591
|
-
this.
|
|
1089
|
+
this.enqueueMerge({
|
|
592
1090
|
level: 2,
|
|
593
1091
|
sourceIds: toMerge.map(s => s.id),
|
|
594
1092
|
});
|
|
595
1093
|
}
|
|
596
1094
|
|
|
597
1095
|
// Check L2 → L3
|
|
598
|
-
const unmergedL2 = this.summaries.filter(
|
|
1096
|
+
const unmergedL2 = this.summaries.filter(
|
|
1097
|
+
s => s.level === 2 && !s.mergedInto && !queuedL2.has(s.id),
|
|
1098
|
+
);
|
|
599
1099
|
if (unmergedL2.length >= threshold) {
|
|
600
1100
|
const toMerge = unmergedL2.slice(0, threshold);
|
|
601
|
-
this.
|
|
1101
|
+
this.enqueueMerge({
|
|
602
1102
|
level: 3,
|
|
603
1103
|
sourceIds: toMerge.map(s => s.id),
|
|
604
1104
|
});
|
|
@@ -627,6 +1127,17 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
627
1127
|
return;
|
|
628
1128
|
}
|
|
629
1129
|
|
|
1130
|
+
// Defensive: if every source is already mergedInto something, this is a
|
|
1131
|
+
// stale queue entry (could happen if multiple merges for the same
|
|
1132
|
+
// sourceIds were enqueued before the dedup fix in checkMergeThreshold).
|
|
1133
|
+
// Skip rather than produce a redundant near-identical higher-level entry.
|
|
1134
|
+
if (sources.every(s => s.mergedInto)) {
|
|
1135
|
+
console.warn(
|
|
1136
|
+
`executeMerge: all sources already merged into ${sources[0].mergedInto}, skipping (stale queue entry)`,
|
|
1137
|
+
);
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
630
1141
|
const targetTokens = this.config.summaryTargetTokens ?? 2000;
|
|
631
1142
|
const participant = this.config.summaryParticipant ?? 'Claude';
|
|
632
1143
|
|
|
@@ -672,7 +1183,6 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
672
1183
|
config: {
|
|
673
1184
|
model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
|
|
674
1185
|
maxTokens: Math.round(targetTokens * 1.5),
|
|
675
|
-
temperature: 0,
|
|
676
1186
|
},
|
|
677
1187
|
};
|
|
678
1188
|
|
|
@@ -691,7 +1201,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
691
1201
|
|
|
692
1202
|
const sourceLevel = (targetLevel - 1) as 0 | 1 | 2;
|
|
693
1203
|
const newEntry: SummaryEntry = {
|
|
694
|
-
id: `L${targetLevel}-${this.
|
|
1204
|
+
id: `L${targetLevel}-${this.nextSummaryIdCounter()}`,
|
|
695
1205
|
level: targetLevel,
|
|
696
1206
|
content: mergedText,
|
|
697
1207
|
tokens: Math.ceil(mergedText.length / 4),
|
|
@@ -701,11 +1211,15 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
701
1211
|
created: Date.now(),
|
|
702
1212
|
};
|
|
703
1213
|
|
|
704
|
-
|
|
1214
|
+
// Append the new merged entry first, then mark sources. Persist each
|
|
1215
|
+
// mergedInto edit individually so chronicle reflects the same shape as
|
|
1216
|
+
// the in-memory mirror. (If the process crashes mid-loop, restart sees
|
|
1217
|
+
// the new entry plus a partial set of marked sources; un-marked sources
|
|
1218
|
+
// would re-trigger a merge — accept the rare duplicate over data loss.)
|
|
1219
|
+
this.pushSummary(newEntry);
|
|
705
1220
|
|
|
706
|
-
// Mark sources as merged
|
|
707
1221
|
for (const source of sources) {
|
|
708
|
-
source
|
|
1222
|
+
this.setMergedInto(source, newEntry.id);
|
|
709
1223
|
}
|
|
710
1224
|
|
|
711
1225
|
// Check if this merge triggers a further merge
|
|
@@ -735,7 +1249,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
735
1249
|
const msg = messages[i];
|
|
736
1250
|
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
737
1251
|
const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
|
|
738
|
-
if (totalTokens + tokens
|
|
1252
|
+
if (this.isOverBudget(totalTokens + tokens, maxTokens)) break;
|
|
739
1253
|
|
|
740
1254
|
entries.push({
|
|
741
1255
|
index: entries.length,
|
|
@@ -772,7 +1286,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
772
1286
|
let l3Used = 0;
|
|
773
1287
|
for (const s of shownL3) {
|
|
774
1288
|
if (l3Used + s.tokens > l3Budget) break;
|
|
775
|
-
if (totalTokens + totalSummaryTokens + s.tokens
|
|
1289
|
+
if (this.isOverBudget(totalTokens + totalSummaryTokens + s.tokens, maxTokens)) break;
|
|
776
1290
|
selectedSummaries.push(s);
|
|
777
1291
|
l3Used += s.tokens;
|
|
778
1292
|
totalSummaryTokens += s.tokens;
|
|
@@ -784,7 +1298,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
784
1298
|
const l2Effective = l2Budget + l3Carryover;
|
|
785
1299
|
for (const s of shownL2) {
|
|
786
1300
|
if (l2Used + s.tokens > l2Effective) break;
|
|
787
|
-
if (totalTokens + totalSummaryTokens + s.tokens
|
|
1301
|
+
if (this.isOverBudget(totalTokens + totalSummaryTokens + s.tokens, maxTokens)) break;
|
|
788
1302
|
selectedSummaries.push(s);
|
|
789
1303
|
l2Used += s.tokens;
|
|
790
1304
|
totalSummaryTokens += s.tokens;
|
|
@@ -800,52 +1314,190 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
800
1314
|
selectedSummaries.push(...l1Selected);
|
|
801
1315
|
totalSummaryTokens += l1Used;
|
|
802
1316
|
|
|
803
|
-
// Emit summaries
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
1317
|
+
// Emit summaries + pinned messages between head and recent windows.
|
|
1318
|
+
//
|
|
1319
|
+
// Default (positionedRecallPairs=true): one Q/A pair per summary,
|
|
1320
|
+
// interleaved with raw pinned messages, all sorted chronologically by
|
|
1321
|
+
// source-range / message position. Each memory appears in its temporal
|
|
1322
|
+
// place rather than as a wall of unrelated recollections.
|
|
1323
|
+
//
|
|
1324
|
+
// Legacy (positionedRecallPairs=false): summaries concatenated into one
|
|
1325
|
+
// Q/A pair between head and tail; pinned messages still emit raw, in
|
|
1326
|
+
// their chronological positions, after the combined recall pair.
|
|
1327
|
+
const positionOf = new Map<string, number>();
|
|
1328
|
+
for (let i = 0; i < messages.length; i++) {
|
|
1329
|
+
positionOf.set(messages[i].id, i);
|
|
1330
|
+
}
|
|
1331
|
+
const pinnedPositionsSet = this.pinnedPositions(messages);
|
|
1332
|
+
// Pinned messages between head and recent (head/recent pinned ones
|
|
1333
|
+
// already emit raw via Phase 0 / Phase 4).
|
|
1334
|
+
const pinnedInMiddle: { msg: StoredMessage; position: number }[] = [];
|
|
1335
|
+
const pinnedIdsInMiddle = new Set<string>();
|
|
1336
|
+
for (let i = headEnd; i < recentStart; i++) {
|
|
1337
|
+
if (pinnedPositionsSet.has(i)) {
|
|
1338
|
+
pinnedInMiddle.push({ msg: messages[i], position: i });
|
|
1339
|
+
pinnedIdsInMiddle.add(messages[i].id);
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
807
1342
|
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
1343
|
+
// Uncompressed-chunk fallback: messages in the middle region whose
|
|
1344
|
+
// chunk hasn't been summarized yet. Without this, a message that
|
|
1345
|
+
// rolled out of the recent window into a queued-but-not-yet-compressed
|
|
1346
|
+
// chunk would vanish from rendered context — there'd be no summary to
|
|
1347
|
+
// emit (compression hasn't run) and Phase 4 only walks recentStart
|
|
1348
|
+
// onwards. Mirrors selectLegacy's "Uncompressed: emit raw" behavior
|
|
1349
|
+
// around line 738, but here we interleave chronologically with
|
|
1350
|
+
// summaries and pins via the unified items list below.
|
|
1351
|
+
//
|
|
1352
|
+
// This matters because compile() was made non-blocking in commit
|
|
1353
|
+
// `3e42e98` (drops the prior `await readiness.pendingWork`); without
|
|
1354
|
+
// this fallback, the trade was silent data-loss for messages caught
|
|
1355
|
+
// in the queued-but-not-yet-compressed window. Now compile()'s
|
|
1356
|
+
// freshness contract is: summaries may lag the very latest L1, but
|
|
1357
|
+
// no message ever disappears.
|
|
1358
|
+
const uncompressedInMiddle: { msg: StoredMessage; position: number }[] = [];
|
|
1359
|
+
for (const chunk of this.chunks) {
|
|
1360
|
+
if (chunk.compressed) continue;
|
|
1361
|
+
for (const msg of chunk.messages) {
|
|
1362
|
+
const pos = positionOf.get(msg.id);
|
|
1363
|
+
if (pos === undefined) continue;
|
|
1364
|
+
if (pos < headEnd || pos >= recentStart) continue;
|
|
1365
|
+
if (pinnedIdsInMiddle.has(msg.id)) continue;
|
|
1366
|
+
uncompressedInMiddle.push({ msg, position: pos });
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
820
1369
|
|
|
821
|
-
|
|
822
|
-
|
|
1370
|
+
// Merged list of raw messages to emit in the middle region —
|
|
1371
|
+
// either a pin or a message whose chunk hasn't compressed yet.
|
|
1372
|
+
// Both render identically (raw, at their chronological position).
|
|
1373
|
+
const middleRaw: { msg: StoredMessage; position: number }[] = [
|
|
1374
|
+
...pinnedInMiddle,
|
|
1375
|
+
...uncompressedInMiddle,
|
|
1376
|
+
];
|
|
823
1377
|
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
totalTokens += pairTokens;
|
|
827
|
-
}
|
|
1378
|
+
if (selectedSummaries.length > 0 || middleRaw.length > 0) {
|
|
1379
|
+
const summaryParticipant = this.config.summaryParticipant ?? 'Claude';
|
|
828
1380
|
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
1381
|
+
if (this.config.positionedRecallPairs !== false) {
|
|
1382
|
+
// Build a unified, chronologically-sorted item list.
|
|
1383
|
+
type Item =
|
|
1384
|
+
| { kind: 'summary'; position: number; summary: SummaryEntry }
|
|
1385
|
+
| { kind: 'pin'; position: number; msg: StoredMessage };
|
|
1386
|
+
|
|
1387
|
+
const items: Item[] = [];
|
|
1388
|
+
for (const s of selectedSummaries) {
|
|
1389
|
+
const pos = positionOf.get(s.sourceRange.first) ?? Number.MAX_SAFE_INTEGER;
|
|
1390
|
+
items.push({ kind: 'summary', position: pos, summary: s });
|
|
1391
|
+
}
|
|
1392
|
+
for (const p of middleRaw) {
|
|
1393
|
+
items.push({ kind: 'pin', position: p.position, msg: p.msg });
|
|
1394
|
+
}
|
|
1395
|
+
items.sort((a, b) => a.position - b.position);
|
|
1396
|
+
|
|
1397
|
+
for (const item of items) {
|
|
1398
|
+
if (item.kind === 'summary') {
|
|
1399
|
+
const summary = item.summary;
|
|
1400
|
+
const headerText = this.buildRecallHeader(summary);
|
|
1401
|
+
const questionEntry: ContextEntry = {
|
|
1402
|
+
index: entries.length,
|
|
1403
|
+
participant: 'Context Manager',
|
|
1404
|
+
content: [{ type: 'text', text: headerText }],
|
|
1405
|
+
sourceRelation: 'derived',
|
|
1406
|
+
};
|
|
1407
|
+
const answerContent: ContentBlock[] = [{ type: 'text', text: summary.content }];
|
|
1408
|
+
const answerEntry: ContextEntry = {
|
|
1409
|
+
index: entries.length + 1,
|
|
1410
|
+
participant: summaryParticipant,
|
|
1411
|
+
content: msgCap > 0 ? this.truncateContent(answerContent, msgCap) : answerContent,
|
|
1412
|
+
sourceRelation: 'derived',
|
|
1413
|
+
};
|
|
1414
|
+
const pairTokens =
|
|
1415
|
+
this.estimateTokens(questionEntry.content) +
|
|
1416
|
+
this.estimateTokens(answerEntry.content);
|
|
1417
|
+
if (this.isOverBudget(totalTokens + pairTokens, maxTokens)) break;
|
|
1418
|
+
entries.push(questionEntry);
|
|
1419
|
+
entries.push(answerEntry);
|
|
1420
|
+
totalTokens += pairTokens;
|
|
1421
|
+
} else {
|
|
1422
|
+
const msg = item.msg;
|
|
1423
|
+
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
1424
|
+
const tokens = msgCap > 0
|
|
1425
|
+
? Math.min(store.estimateTokens(msg), msgCap + 50)
|
|
1426
|
+
: store.estimateTokens(msg);
|
|
1427
|
+
if (this.isOverBudget(totalTokens + tokens, maxTokens)) break;
|
|
1428
|
+
entries.push({
|
|
1429
|
+
index: entries.length,
|
|
1430
|
+
sourceMessageId: msg.id,
|
|
1431
|
+
sourceRelation: 'copy',
|
|
1432
|
+
participant: msg.participant,
|
|
1433
|
+
content,
|
|
1434
|
+
});
|
|
1435
|
+
totalTokens += tokens;
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
} else {
|
|
1439
|
+
// Legacy combined-pair mode for summaries; pins still emit raw at
|
|
1440
|
+
// their positions after the combined pair.
|
|
1441
|
+
if (selectedSummaries.length > 0) {
|
|
1442
|
+
const contextLabel = this.config.summaryContextLabel ?? 'What do you remember from earlier?';
|
|
1443
|
+
const combinedText = selectedSummaries.map(s => s.content).join('\n\n---\n\n');
|
|
835
1444
|
|
|
836
|
-
|
|
1445
|
+
const questionEntry: ContextEntry = {
|
|
1446
|
+
index: entries.length,
|
|
1447
|
+
participant: 'Context Manager',
|
|
1448
|
+
content: [{ type: 'text', text: contextLabel }],
|
|
1449
|
+
sourceRelation: 'derived',
|
|
1450
|
+
};
|
|
1451
|
+
// Synthesised summary turns must respect maxMessageTokens. With L1+L2+L3
|
|
1452
|
+
// budgets defaulting to 30k each, an unconstrained concatenation can push
|
|
1453
|
+
// a single assistant turn past 90k tokens, eating the inference budget
|
|
1454
|
+
// and starving recent messages (postmortem 2026-05-04, bug B).
|
|
1455
|
+
const answerContent: ContentBlock[] = [{ type: 'text', text: combinedText }];
|
|
1456
|
+
const answerEntry: ContextEntry = {
|
|
1457
|
+
index: entries.length + 1,
|
|
1458
|
+
participant: summaryParticipant,
|
|
1459
|
+
content: msgCap > 0 ? this.truncateContent(answerContent, msgCap) : answerContent,
|
|
1460
|
+
sourceRelation: 'derived',
|
|
1461
|
+
};
|
|
1462
|
+
|
|
1463
|
+
const pairTokens = this.estimateTokens(questionEntry.content) +
|
|
1464
|
+
this.estimateTokens(answerEntry.content);
|
|
1465
|
+
|
|
1466
|
+
entries.push(questionEntry);
|
|
1467
|
+
entries.push(answerEntry);
|
|
1468
|
+
totalTokens += pairTokens;
|
|
1469
|
+
}
|
|
837
1470
|
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
1471
|
+
// Sort by position so uncompressed-middle messages and pins both
|
|
1472
|
+
// appear in their chronological place after the combined recall pair.
|
|
1473
|
+
const middleRawSorted = [...middleRaw].sort((a, b) => a.position - b.position);
|
|
1474
|
+
for (const { msg } of middleRawSorted) {
|
|
1475
|
+
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
1476
|
+
const tokens = msgCap > 0
|
|
1477
|
+
? Math.min(store.estimateTokens(msg), msgCap + 50)
|
|
1478
|
+
: store.estimateTokens(msg);
|
|
1479
|
+
if (this.isOverBudget(totalTokens + tokens, maxTokens)) break;
|
|
1480
|
+
entries.push({
|
|
1481
|
+
index: entries.length,
|
|
1482
|
+
sourceMessageId: msg.id,
|
|
1483
|
+
sourceRelation: 'copy',
|
|
1484
|
+
participant: msg.participant,
|
|
1485
|
+
content,
|
|
1486
|
+
});
|
|
1487
|
+
totalTokens += tokens;
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
846
1490
|
}
|
|
847
1491
|
|
|
1492
|
+
// Phase 4: Recent uncompressed messages (skip head window overlap).
|
|
1493
|
+
// Newest-first eviction so that when summaries/head consume most of the
|
|
1494
|
+
// budget, the latest messages (the ones the agent actually needs to act
|
|
1495
|
+
// on) are preserved and the oldest recent-window messages are dropped.
|
|
1496
|
+
const effectiveRecentStart = Math.max(recentStart, headEnd);
|
|
1497
|
+
this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
|
|
1498
|
+
|
|
848
1499
|
this.trimOrphanedToolUse(entries);
|
|
1500
|
+
this.pruneToolEntries(entries);
|
|
849
1501
|
return entries;
|
|
850
1502
|
}
|
|
851
1503
|
|
|
@@ -886,13 +1538,60 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
886
1538
|
let used = 0;
|
|
887
1539
|
for (const s of shownL1) {
|
|
888
1540
|
if (used + s.tokens > budget) break;
|
|
889
|
-
if (used + s.tokens
|
|
1541
|
+
if (this.isOverBudget(used + s.tokens, maxTokens)) break;
|
|
890
1542
|
selected.push(s);
|
|
891
1543
|
used += s.tokens;
|
|
892
1544
|
}
|
|
893
1545
|
return { selected, tokensUsed: used };
|
|
894
1546
|
}
|
|
895
1547
|
|
|
1548
|
+
/**
|
|
1549
|
+
* True if `projectedTotal` exceeds `max` AND the strategy is configured
|
|
1550
|
+
* to enforce budget. When `enforceBudget: false`, always returns false
|
|
1551
|
+
* — the rendering pipeline emits the full ideal context regardless of
|
|
1552
|
+
* budget overage. Caller's API will reject if it exceeds the model's
|
|
1553
|
+
* context window; the philosophy is "surface overage, don't hide it."
|
|
1554
|
+
*/
|
|
1555
|
+
protected isOverBudget(projectedTotal: number, max: number): boolean {
|
|
1556
|
+
if (this.config.enforceBudget === false) return false;
|
|
1557
|
+
return projectedTotal > max;
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
/**
|
|
1561
|
+
* Sort selected summaries by source-range start position, so per-pair
|
|
1562
|
+
* recall emission appears in chronological order. Falls back to the
|
|
1563
|
+
* created timestamp for summaries whose source-range first message is
|
|
1564
|
+
* no longer in the store.
|
|
1565
|
+
*/
|
|
1566
|
+
protected sortSummariesChronologically(
|
|
1567
|
+
summaries: SummaryEntry[],
|
|
1568
|
+
messages: StoredMessage[],
|
|
1569
|
+
): SummaryEntry[] {
|
|
1570
|
+
const positionOf = new Map<string, number>();
|
|
1571
|
+
for (let i = 0; i < messages.length; i++) {
|
|
1572
|
+
positionOf.set(messages[i].id, i);
|
|
1573
|
+
}
|
|
1574
|
+
return [...summaries].sort((a, b) => {
|
|
1575
|
+
const posA = positionOf.get(a.sourceRange.first) ?? Number.MAX_SAFE_INTEGER;
|
|
1576
|
+
const posB = positionOf.get(b.sourceRange.first) ?? Number.MAX_SAFE_INTEGER;
|
|
1577
|
+
if (posA !== posB) return posA - posB;
|
|
1578
|
+
return a.created - b.created;
|
|
1579
|
+
});
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
/**
|
|
1583
|
+
* Render the per-pair recall header from the configured template.
|
|
1584
|
+
* Substitutions: {id} {level} {first} {last}.
|
|
1585
|
+
*/
|
|
1586
|
+
protected buildRecallHeader(summary: SummaryEntry): string {
|
|
1587
|
+
const template = this.config.recallHeaderTemplate ?? '[Recall {id}]';
|
|
1588
|
+
return template
|
|
1589
|
+
.replace(/\{id\}/g, summary.id)
|
|
1590
|
+
.replace(/\{level\}/g, String(summary.level))
|
|
1591
|
+
.replace(/\{first\}/g, summary.sourceRange.first)
|
|
1592
|
+
.replace(/\{last\}/g, summary.sourceRange.last);
|
|
1593
|
+
}
|
|
1594
|
+
|
|
896
1595
|
// ============================================================================
|
|
897
1596
|
// Head window reset / topic transition
|
|
898
1597
|
// ============================================================================
|
|
@@ -959,7 +1658,6 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
959
1658
|
config: {
|
|
960
1659
|
model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
|
|
961
1660
|
maxTokens: 1500,
|
|
962
|
-
temperature: 0,
|
|
963
1661
|
},
|
|
964
1662
|
};
|
|
965
1663
|
|
|
@@ -995,16 +1693,24 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
995
1693
|
// ============================================================================
|
|
996
1694
|
|
|
997
1695
|
/**
|
|
998
|
-
* Get messages in the compressible zone: outside both head window and
|
|
999
|
-
*
|
|
1696
|
+
* Get messages in the compressible zone: outside both head window and
|
|
1697
|
+
* recent window AND not inside any pinned range. Returns messages from
|
|
1698
|
+
* [0, headStart) ∪ [headEnd, recentStart) minus any positions covered
|
|
1699
|
+
* by a pin or document mark.
|
|
1000
1700
|
*/
|
|
1001
1701
|
protected getCompressibleMessages(store: MessageStoreView): StoredMessage[] {
|
|
1002
1702
|
const messages = store.getAll();
|
|
1003
1703
|
const headStart = this.getHeadWindowStartIndex(store);
|
|
1004
1704
|
const headEnd = this.getHeadWindowEnd(store);
|
|
1005
1705
|
const recentStart = this.getRecentWindowStart(store);
|
|
1006
|
-
|
|
1007
|
-
|
|
1706
|
+
const pinned = this.pinnedPositions(messages);
|
|
1707
|
+
const out: StoredMessage[] = [];
|
|
1708
|
+
for (let i = 0; i < recentStart; i++) {
|
|
1709
|
+
if (i >= headStart && i < headEnd) continue;
|
|
1710
|
+
if (pinned.has(i)) continue;
|
|
1711
|
+
out.push(messages[i]);
|
|
1712
|
+
}
|
|
1713
|
+
return out;
|
|
1008
1714
|
}
|
|
1009
1715
|
|
|
1010
1716
|
/**
|
|
@@ -1220,6 +1926,91 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1220
1926
|
}
|
|
1221
1927
|
}
|
|
1222
1928
|
|
|
1929
|
+
/**
|
|
1930
|
+
* Prune tool_use / tool_result blocks in-place:
|
|
1931
|
+
* 1. Truncate `tool_use.input` blocks whose serialized JSON exceeds
|
|
1932
|
+
* `toolUseInputMaxTokens`.
|
|
1933
|
+
* 2. For each tool name, keep only the last N `tool_result` blocks
|
|
1934
|
+
* per `toolResultMaxLastN`; older ones get their `content` replaced
|
|
1935
|
+
* with a brief marker referencing the tool name and how many newer
|
|
1936
|
+
* results exist below.
|
|
1937
|
+
*
|
|
1938
|
+
* Both passes are no-ops when the corresponding config is unset/0.
|
|
1939
|
+
* Pruning runs AFTER selection and orphan-trimming, so it doesn't
|
|
1940
|
+
* affect chunk formation or the recall/pin layout.
|
|
1941
|
+
*/
|
|
1942
|
+
protected pruneToolEntries(entries: ContextEntry[]): void {
|
|
1943
|
+
// Pass 1: build toolUseId → toolName map and apply input truncation
|
|
1944
|
+
const toolUseInputCap = this.config.toolUseInputMaxTokens ?? 0;
|
|
1945
|
+
const toolUseIdToName = new Map<string, string>();
|
|
1946
|
+
|
|
1947
|
+
for (const entry of entries) {
|
|
1948
|
+
for (let i = 0; i < entry.content.length; i++) {
|
|
1949
|
+
const block = entry.content[i];
|
|
1950
|
+
if (block.type !== 'tool_use') continue;
|
|
1951
|
+
toolUseIdToName.set(block.id, block.name);
|
|
1952
|
+
|
|
1953
|
+
if (toolUseInputCap > 0) {
|
|
1954
|
+
const inputJson = JSON.stringify(block.input);
|
|
1955
|
+
const inputTokens = Math.ceil(inputJson.length / 4);
|
|
1956
|
+
if (inputTokens > toolUseInputCap) {
|
|
1957
|
+
const keys = Object.keys(block.input).slice(0, 5);
|
|
1958
|
+
entry.content[i] = {
|
|
1959
|
+
...block,
|
|
1960
|
+
input: {
|
|
1961
|
+
_truncated: true,
|
|
1962
|
+
_originalTokens: inputTokens,
|
|
1963
|
+
_keys: keys,
|
|
1964
|
+
},
|
|
1965
|
+
};
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
// Pass 2: collect tool_result occurrences per tool name, in order
|
|
1972
|
+
const occurrencesByTool = new Map<string, Array<{ entry: ContextEntry; blockIndex: number }>>();
|
|
1973
|
+
for (const entry of entries) {
|
|
1974
|
+
for (let i = 0; i < entry.content.length; i++) {
|
|
1975
|
+
const block = entry.content[i];
|
|
1976
|
+
if (block.type !== 'tool_result') continue;
|
|
1977
|
+
const toolName = toolUseIdToName.get(block.toolUseId);
|
|
1978
|
+
if (!toolName) continue;
|
|
1979
|
+
let arr = occurrencesByTool.get(toolName);
|
|
1980
|
+
if (!arr) {
|
|
1981
|
+
arr = [];
|
|
1982
|
+
occurrencesByTool.set(toolName, arr);
|
|
1983
|
+
}
|
|
1984
|
+
arr.push({ entry, blockIndex: i });
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
// Pass 3: apply per-tool max-last-N
|
|
1989
|
+
const cfg = this.config.toolResultMaxLastN;
|
|
1990
|
+
if (cfg === undefined) return;
|
|
1991
|
+
|
|
1992
|
+
for (const [toolName, occs] of occurrencesByTool) {
|
|
1993
|
+
let limit: number | undefined;
|
|
1994
|
+
if (typeof cfg === 'number') limit = cfg;
|
|
1995
|
+
else if (typeof cfg === 'object') limit = cfg[toolName];
|
|
1996
|
+
if (limit === undefined || limit < 0) continue;
|
|
1997
|
+
|
|
1998
|
+
const excessCount = occs.length - limit;
|
|
1999
|
+
if (excessCount <= 0) continue;
|
|
2000
|
+
|
|
2001
|
+
for (let i = 0; i < excessCount; i++) {
|
|
2002
|
+
const { entry, blockIndex } = occs[i];
|
|
2003
|
+
const orig = entry.content[blockIndex];
|
|
2004
|
+
if (orig.type !== 'tool_result') continue;
|
|
2005
|
+
const fresherCount = occs.length - i - 1;
|
|
2006
|
+
entry.content[blockIndex] = {
|
|
2007
|
+
...orig,
|
|
2008
|
+
content: `[Result truncated — tool '${toolName}' has ${fresherCount} more recent result${fresherCount === 1 ? '' : 's'} below]`,
|
|
2009
|
+
};
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
|
|
1223
2014
|
protected isChunkOldEnough(chunk: Chunk): boolean {
|
|
1224
2015
|
return true;
|
|
1225
2016
|
}
|