@animalabs/context-manager 0.5.0 → 0.5.2
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.map +1 -1
- package/dist/src/context-manager.js +25 -6
- package/dist/src/context-manager.js.map +1 -1
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/message-store.d.ts.map +1 -1
- package/dist/src/message-store.js +7 -36
- package/dist/src/message-store.js.map +1 -1
- package/dist/src/normalize-tool-messages.d.ts +40 -0
- package/dist/src/normalize-tool-messages.d.ts.map +1 -0
- package/dist/src/normalize-tool-messages.js +69 -0
- package/dist/src/normalize-tool-messages.js.map +1 -0
- package/dist/src/strategies/autobiographical.d.ts +20 -0
- package/dist/src/strategies/autobiographical.d.ts.map +1 -1
- package/dist/src/strategies/autobiographical.js +126 -33
- package/dist/src/strategies/autobiographical.js.map +1 -1
- package/dist/src/types/strategy.d.ts +23 -0
- package/dist/src/types/strategy.d.ts.map +1 -1
- package/dist/src/types/strategy.js.map +1 -1
- package/dist/test/normalize-tool-messages.test.d.ts +2 -0
- package/dist/test/normalize-tool-messages.test.d.ts.map +1 -0
- package/dist/test/normalize-tool-messages.test.js +108 -0
- package/dist/test/normalize-tool-messages.test.js.map +1 -0
- package/dist/test/recall-cap.test.d.ts +2 -0
- package/dist/test/recall-cap.test.d.ts.map +1 -0
- package/dist/test/recall-cap.test.js +103 -0
- package/dist/test/recall-cap.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/context-manager.ts +25 -6
- package/src/index.ts +3 -0
- package/src/message-store.ts +12 -38
- package/src/normalize-tool-messages.ts +71 -0
- package/src/strategies/autobiographical.ts +143 -33
- package/src/types/strategy.ts +24 -0
package/src/context-manager.ts
CHANGED
|
@@ -25,6 +25,7 @@ import type { RenderStats } from './types/index.js';
|
|
|
25
25
|
import { MessageStore, MessageStoreEvent } from './message-store.js';
|
|
26
26
|
import { ContextLog } from './context-log.js';
|
|
27
27
|
import { PassthroughStrategy } from './strategies/passthrough.js';
|
|
28
|
+
import { splitMixedToolMessages } from './normalize-tool-messages.js';
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
31
|
* Base configuration for ContextManager.
|
|
@@ -493,12 +494,30 @@ export class ContextManager {
|
|
|
493
494
|
effectiveBudget
|
|
494
495
|
);
|
|
495
496
|
|
|
496
|
-
// Convert to NormalizedMessage[]
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
497
|
+
// Convert to NormalizedMessage[]. We split each entry individually
|
|
498
|
+
// so we know the output-count per input and can re-attach cache
|
|
499
|
+
// markers to the last output of each (matching the marker's
|
|
500
|
+
// "cache up to here" intent).
|
|
501
|
+
//
|
|
502
|
+
// Splitting handles the claude.ai bundled-tool-cycle artifact: a
|
|
503
|
+
// non-user message containing `tool_result` blocks becomes a sequence
|
|
504
|
+
// of agent/user/agent turns so the API accepts it. Already-API-shape
|
|
505
|
+
// messages pass through untouched. See `src/normalize-tool-messages.ts`.
|
|
506
|
+
const messages: NormalizedMessage[] = [];
|
|
507
|
+
for (const entry of entries) {
|
|
508
|
+
const splitParts = splitMixedToolMessages([
|
|
509
|
+
{ participant: entry.participant, content: entry.content },
|
|
510
|
+
]);
|
|
511
|
+
for (let i = 0; i < splitParts.length; i++) {
|
|
512
|
+
const part = splitParts[i];
|
|
513
|
+
const isLast = i === splitParts.length - 1;
|
|
514
|
+
messages.push({
|
|
515
|
+
participant: part.participant,
|
|
516
|
+
content: part.content,
|
|
517
|
+
...(entry.cacheMarker && isLast ? { cacheBreakpoint: true } : {}),
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
}
|
|
502
521
|
|
|
503
522
|
// If no injections, log and return early
|
|
504
523
|
if (!injections || injections.length === 0) {
|
package/src/index.ts
CHANGED
|
@@ -13,6 +13,9 @@ export { PassthroughStrategy } from './strategies/passthrough.js';
|
|
|
13
13
|
export { AutobiographicalStrategy, type AutobiographicalProgressSnapshot } from './strategies/autobiographical.js';
|
|
14
14
|
export { KnowledgeStrategy } from './strategies/knowledge.js';
|
|
15
15
|
|
|
16
|
+
// Utilities
|
|
17
|
+
export { splitMixedToolMessages } from './normalize-tool-messages.js';
|
|
18
|
+
|
|
16
19
|
// Types
|
|
17
20
|
export type {
|
|
18
21
|
// Message types
|
package/src/message-store.ts
CHANGED
|
@@ -130,7 +130,6 @@ export class MessageStore {
|
|
|
130
130
|
// Extract blobs from content
|
|
131
131
|
const storedContent = this.blobManager.extractBlobs(content);
|
|
132
132
|
|
|
133
|
-
// Append to Chronicle first to get the record ID
|
|
134
133
|
const partialInternal = {
|
|
135
134
|
participant,
|
|
136
135
|
content: storedContent,
|
|
@@ -140,45 +139,22 @@ export class MessageStore {
|
|
|
140
139
|
...(extra ?? {}),
|
|
141
140
|
};
|
|
142
141
|
|
|
143
|
-
|
|
142
|
+
// Single atomic op: chronicle peeks the next record id+sequence under its
|
|
143
|
+
// write lock, splices them into the payload as `id` and `sequence`, and
|
|
144
|
+
// writes one record. The reconstructed state sees a fully-populated
|
|
145
|
+
// StoredMessageInternal, and `branchAt(messageId)` forks at this
|
|
146
|
+
// message's own sequence — exactly the post-fork-visible point.
|
|
147
|
+
const record = this.store.appendToStateJsonWithIdentity(
|
|
148
|
+
this.stateId,
|
|
149
|
+
partialInternal,
|
|
150
|
+
'id',
|
|
151
|
+
'sequence',
|
|
152
|
+
);
|
|
144
153
|
const index = this.length() - 1;
|
|
145
154
|
|
|
146
|
-
// The append's payload doesn't carry the chronicle record's id /
|
|
147
|
-
// sequence — those are only known once the append returns. We patch
|
|
148
|
-
// them in via editStateItem below. The patch creates a new chronicle
|
|
149
|
-
// record at `record.sequence + 1` (the next sequence; nothing else
|
|
150
|
-
// writes between the append and the edit inside this function).
|
|
151
|
-
//
|
|
152
|
-
// We store the EDIT'S sequence (not the original append's) as the
|
|
153
|
-
// canonical "this message is fully readable at and after this seq"
|
|
154
|
-
// marker. Why: `ContextManager.branchAt(messageId)` forwards
|
|
155
|
-
// `message.sequence` to `chronicle.createBranchAt`, which forks
|
|
156
|
-
// visibility at that exact seq. If `sequence` pointed at the original
|
|
157
|
-
// append (record.sequence), the new branch would see the pre-patch
|
|
158
|
-
// payload (id/sequence undefined). Pointing at the edit's seq
|
|
159
|
-
// includes the patched payload in the inherited records — so a
|
|
160
|
-
// forked branch sees this message in its fully-established form.
|
|
161
|
-
//
|
|
162
|
-
// Invariant: this function does exactly ONE chronicle write between
|
|
163
|
-
// the append and the edit (the edit itself). If that ever changes,
|
|
164
|
-
// the +1 expression must be updated to match (or refactored to
|
|
165
|
-
// capture editStateItem's returned record.sequence — which is
|
|
166
|
-
// chicken-and-egg here since we need the value inside the payload
|
|
167
|
-
// we're writing).
|
|
168
|
-
const commitSequence = record.sequence + 1;
|
|
169
|
-
const fullInternal: StoredMessageInternal = {
|
|
170
|
-
id: record.id,
|
|
171
|
-
sequence: commitSequence,
|
|
172
|
-
...partialInternal,
|
|
173
|
-
};
|
|
174
|
-
this.store.editStateItem(this.stateId, index, Buffer.from(JSON.stringify(fullInternal)));
|
|
175
|
-
|
|
176
|
-
// Build full message with ID and sequence from record. The sequence
|
|
177
|
-
// we expose is the commit sequence (matches what's stored), so
|
|
178
|
-
// `branchAt(messageId)` forks at the right place for any caller.
|
|
179
155
|
const message: StoredMessage = {
|
|
180
156
|
id: record.id,
|
|
181
|
-
sequence:
|
|
157
|
+
sequence: record.sequence,
|
|
182
158
|
participant,
|
|
183
159
|
content, // Original content with inline data
|
|
184
160
|
metadata,
|
|
@@ -187,9 +163,7 @@ export class MessageStore {
|
|
|
187
163
|
...(extra ?? {}),
|
|
188
164
|
};
|
|
189
165
|
|
|
190
|
-
// Update index
|
|
191
166
|
this.idToIndex.set(message.id, index);
|
|
192
|
-
|
|
193
167
|
this.emit({ type: 'add', message });
|
|
194
168
|
return message;
|
|
195
169
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { ContentBlock } from '@animalabs/membrane';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Anthropic-API shape requires `tool_use` blocks in assistant turns and
|
|
5
|
+
* `tool_result` blocks in user turns, with each tool_result in the message
|
|
6
|
+
* *immediately following* its tool_use. claude.ai exports bundle the entire
|
|
7
|
+
* tool cycle into a single assistant message, and any downstream consumer
|
|
8
|
+
* that ferries those messages through unchanged hits a 400.
|
|
9
|
+
*
|
|
10
|
+
* This helper walks a list of messages and, for every non-user message
|
|
11
|
+
* containing one or more `tool_result` blocks, splits it so:
|
|
12
|
+
*
|
|
13
|
+
* - blocks before the first tool_result stay under the original participant
|
|
14
|
+
* - each run of consecutive tool_results becomes its own `user` message
|
|
15
|
+
* - blocks after the tool_results go back to the original participant
|
|
16
|
+
*
|
|
17
|
+
* Messages that are already user-side or that don't contain tool_results
|
|
18
|
+
* pass through unchanged. The participant-name check is case-insensitive
|
|
19
|
+
* (matching the convention used elsewhere in CM) so it works regardless
|
|
20
|
+
* of whether the session uses 'user', 'User', or some custom label.
|
|
21
|
+
*
|
|
22
|
+
* Called by:
|
|
23
|
+
* - `ContextManager.compile()` — live session path, prevents the
|
|
24
|
+
* bundled-cycle 400 for already-imported sessions.
|
|
25
|
+
* - `AutobiographicalStrategy.compressChunkHierarchical()` — defense in
|
|
26
|
+
* depth at the compression LLM call site.
|
|
27
|
+
* - `AutobiographicalStrategy.executeMerge()` — same, at the merge call site.
|
|
28
|
+
*
|
|
29
|
+
* For new imports, conhost's `scripts/import-claudeai-export.ts` already
|
|
30
|
+
* splits at ingest time so Chronicle stores hold API-shape messages. This
|
|
31
|
+
* runtime pass handles legacy sessions imported before that fix landed,
|
|
32
|
+
* and acts as a safety net for any future bundling source.
|
|
33
|
+
*/
|
|
34
|
+
export function splitMixedToolMessages<T extends { participant: string; content: ContentBlock[] }>(
|
|
35
|
+
messages: readonly T[],
|
|
36
|
+
): Array<{ participant: string; content: ContentBlock[] }> {
|
|
37
|
+
const out: Array<{ participant: string; content: ContentBlock[] }> = [];
|
|
38
|
+
for (const msg of messages) {
|
|
39
|
+
const isUser = msg.participant.toLowerCase() === 'user';
|
|
40
|
+
if (isUser || !msg.content.some((b) => b.type === 'tool_result')) {
|
|
41
|
+
out.push({ participant: msg.participant, content: msg.content });
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
let preTool: ContentBlock[] = [];
|
|
45
|
+
let pendingResults: ContentBlock[] = [];
|
|
46
|
+
const flushPre = () => {
|
|
47
|
+
if (preTool.length > 0) {
|
|
48
|
+
out.push({ participant: msg.participant, content: preTool });
|
|
49
|
+
preTool = [];
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
const flushResults = () => {
|
|
53
|
+
if (pendingResults.length > 0) {
|
|
54
|
+
out.push({ participant: 'user', content: pendingResults });
|
|
55
|
+
pendingResults = [];
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
for (const block of msg.content) {
|
|
59
|
+
if (block.type === 'tool_result') {
|
|
60
|
+
flushPre();
|
|
61
|
+
pendingResults.push(block);
|
|
62
|
+
} else {
|
|
63
|
+
flushResults();
|
|
64
|
+
preTool.push(block);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
flushResults();
|
|
68
|
+
flushPre();
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
@@ -20,6 +20,7 @@ import type {
|
|
|
20
20
|
} from '../types/index.js';
|
|
21
21
|
import { DEFAULT_AUTOBIOGRAPHICAL_CONFIG } from '../types/index.js';
|
|
22
22
|
import { getSummaryParentId } from '../types/strategy.js';
|
|
23
|
+
import { splitMixedToolMessages } from '../normalize-tool-messages.js';
|
|
23
24
|
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
24
25
|
import { dirname } from 'node:path';
|
|
25
26
|
import { Picker, OverBudgetError, type PickerChunk } from '../adaptive/picker.js';
|
|
@@ -747,6 +748,39 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
747
748
|
* Mark a summary as merged into a higher-level summary, updating the
|
|
748
749
|
* chronicle copy at the same index. Index is the position in `this.summaries`.
|
|
749
750
|
*/
|
|
751
|
+
/**
|
|
752
|
+
* Token-budget cap for recall-pair summary sets. Walks newest→oldest
|
|
753
|
+
* keeping each summary that still fits; skips (rather than breaks at)
|
|
754
|
+
* a summary that would put us over budget, so a heterogeneous set
|
|
755
|
+
* fills the remaining slots with smaller siblings instead of stopping
|
|
756
|
+
* at the first oversized one. The kept set is re-sorted chronologically.
|
|
757
|
+
*
|
|
758
|
+
* Used by both `compressChunkHierarchical` (the L1 compression prompt
|
|
759
|
+
* recall pairs) and `executeMerge` (the merge prompt recall pairs).
|
|
760
|
+
* Without the cap, both sites grow their recall set linearly with
|
|
761
|
+
* conversation length and overflow the 200k window around the same
|
|
762
|
+
* point — observed empirically at ~chunk 118 in a 4000-message import.
|
|
763
|
+
*
|
|
764
|
+
* Per-summary +50 token overhead accounts for the "[CM] Recall memory
|
|
765
|
+
* <id>." question turn that wraps each recall body. Rough but defensive.
|
|
766
|
+
*/
|
|
767
|
+
protected capRecallPairs(
|
|
768
|
+
summariesChronological: SummaryEntry[],
|
|
769
|
+
maxTokens: number,
|
|
770
|
+
): { kept: SummaryEntry[]; keptTokens: number } {
|
|
771
|
+
const kept: SummaryEntry[] = [];
|
|
772
|
+
let total = 0;
|
|
773
|
+
for (let i = summariesChronological.length - 1; i >= 0; i--) {
|
|
774
|
+
const s = summariesChronological[i]!;
|
|
775
|
+
const est = (s.tokens ?? Math.ceil(s.content.length / 4)) + 50;
|
|
776
|
+
if (total + est > maxTokens) continue;
|
|
777
|
+
kept.push(s);
|
|
778
|
+
total += est;
|
|
779
|
+
}
|
|
780
|
+
kept.reverse();
|
|
781
|
+
return { kept, keptTokens: total };
|
|
782
|
+
}
|
|
783
|
+
|
|
750
784
|
protected setMergedInto(entry: SummaryEntry, mergedIntoId: string): void {
|
|
751
785
|
entry.mergedInto = mergedIntoId;
|
|
752
786
|
if (!this.store) return;
|
|
@@ -1507,11 +1541,14 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1507
1541
|
|
|
1508
1542
|
// Build the KV-preserving prompt per hermes-autobio spec:
|
|
1509
1543
|
//
|
|
1510
|
-
// 1. Prior
|
|
1511
|
-
// pairs, in source order.
|
|
1512
|
-
//
|
|
1544
|
+
// 1. Prior summaries — narrativized as CM-asks / agent-recalls
|
|
1545
|
+
// pairs, in source order. The unmerged frontier of the
|
|
1546
|
+
// summary forest: any summary that has not yet been merged
|
|
1547
|
+
// into a higher level. After merges run, the L_{k+1} replaces
|
|
1548
|
+
// its L_k children — using the children plus their parent
|
|
1549
|
+
// doubles the prompt size unboundedly.
|
|
1513
1550
|
// 2. Head — raw messages before the chunk that aren't already
|
|
1514
|
-
// represented by a prior
|
|
1551
|
+
// represented by a prior summary.
|
|
1515
1552
|
// 3. Marker — in-band signal that a memory is about to form.
|
|
1516
1553
|
// 4. Chunk — raw messages being compressed, as the agent
|
|
1517
1554
|
// experienced them.
|
|
@@ -1522,13 +1559,42 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1522
1559
|
// as-of framing of memory formation.
|
|
1523
1560
|
const llmMessages: Array<{ participant: string; content: ContentBlock[] }> = [];
|
|
1524
1561
|
|
|
1525
|
-
// ---- 1. Prior
|
|
1526
|
-
//
|
|
1527
|
-
//
|
|
1528
|
-
|
|
1529
|
-
|
|
1562
|
+
// ---- 1. Prior recall pairs ----
|
|
1563
|
+
// Filter to the unmerged frontier: any summary whose `mergedInto`
|
|
1564
|
+
// is unset. After merge, the children's mergedInto points at the
|
|
1565
|
+
// parent and the parent stands alone with that source range. The
|
|
1566
|
+
// original "ALL L1s regardless of merge state" rule was a fidelity
|
|
1567
|
+
// optimization that scales catastrophically: a 4000-message import
|
|
1568
|
+
// converged to ~500 L1s that never aged out, blowing the 200k
|
|
1569
|
+
// window around chunk 118.
|
|
1570
|
+
const priorSummaries = this.summaries
|
|
1571
|
+
.filter((s) => !s.mergedInto)
|
|
1530
1572
|
.sort((a, b) => a.sourceRange.first.localeCompare(b.sourceRange.first));
|
|
1531
|
-
|
|
1573
|
+
|
|
1574
|
+
// Token-budget cap (see capRecallPairs). Defense-in-depth: even with
|
|
1575
|
+
// merged exclusion the unmerged frontier can be large at extreme scale.
|
|
1576
|
+
const recallBudget = this.config.compressionRecallBudgetTokens ?? 100_000;
|
|
1577
|
+
const { kept: keptSummaries, keptTokens: recallTokens } = this.capRecallPairs(
|
|
1578
|
+
priorSummaries,
|
|
1579
|
+
recallBudget,
|
|
1580
|
+
);
|
|
1581
|
+
if (keptSummaries.length < priorSummaries.length) {
|
|
1582
|
+
const dropped = priorSummaries.length - keptSummaries.length;
|
|
1583
|
+
console.warn(
|
|
1584
|
+
`autobio: compression recall-pair budget capped (${keptSummaries.length}/${priorSummaries.length} summaries kept, ` +
|
|
1585
|
+
`~${recallTokens} tokens, budget ${recallBudget}; ${dropped} oldest dropped this compression).`,
|
|
1586
|
+
);
|
|
1587
|
+
logCompressionCall({
|
|
1588
|
+
event: 'recall-budget-capped',
|
|
1589
|
+
site: 'compression',
|
|
1590
|
+
kept: keptSummaries.length,
|
|
1591
|
+
total: priorSummaries.length,
|
|
1592
|
+
tokens: recallTokens,
|
|
1593
|
+
budgetTokens: recallBudget,
|
|
1594
|
+
});
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
for (const s of keptSummaries) {
|
|
1532
1598
|
llmMessages.push({
|
|
1533
1599
|
participant: 'Context Manager',
|
|
1534
1600
|
content: [{ type: 'text', text: `[CM] Recall memory ${s.id}.` }],
|
|
@@ -1567,19 +1633,23 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1567
1633
|
}
|
|
1568
1634
|
|
|
1569
1635
|
// Any raw messages between the head and the chunk that aren't yet
|
|
1570
|
-
// represented by
|
|
1571
|
-
// since chunking proceeds contiguously and
|
|
1572
|
-
// up to the chunk being processed.
|
|
1636
|
+
// represented by any summary — usually empty in adaptive-resolution
|
|
1637
|
+
// mode, since chunking proceeds contiguously and summaries cover
|
|
1638
|
+
// everything up to the chunk being processed. Uses the full
|
|
1639
|
+
// priorSummaries set (not the budget-capped keptSummaries) because
|
|
1640
|
+
// the dedup question is "is this raw message covered by *any* live
|
|
1641
|
+
// summary?" — a budget-dropped summary doesn't make the underlying
|
|
1642
|
+
// raw messages reappear.
|
|
1573
1643
|
const chunkFirstId = chunk.messages[0]?.id;
|
|
1574
1644
|
if (chunkFirstId) {
|
|
1575
|
-
const
|
|
1576
|
-
for (const s of
|
|
1577
|
-
for (const id of s.sourceIds)
|
|
1645
|
+
const priorSummaryMessageIds = new Set<string>();
|
|
1646
|
+
for (const s of priorSummaries) {
|
|
1647
|
+
for (const id of s.sourceIds) priorSummaryMessageIds.add(id);
|
|
1578
1648
|
}
|
|
1579
1649
|
const chunkStartIdx = allMessages.findIndex((m) => m.id === chunkFirstId);
|
|
1580
1650
|
for (let i = headEndIdx; i < chunkStartIdx && i < allMessages.length; i++) {
|
|
1581
1651
|
const m = allMessages[i];
|
|
1582
|
-
if (
|
|
1652
|
+
if (priorSummaryMessageIds.has(m.id)) continue;
|
|
1583
1653
|
llmMessages.push({ participant: m.participant, content: m.content });
|
|
1584
1654
|
}
|
|
1585
1655
|
}
|
|
@@ -1613,8 +1683,15 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1613
1683
|
content: [{ type: 'text', text: instructionText }],
|
|
1614
1684
|
});
|
|
1615
1685
|
|
|
1686
|
+
// Split any bundled tool_use+tool_result cycles in non-user turns into
|
|
1687
|
+
// separate API-shape messages. claude.ai-imported sessions carry these
|
|
1688
|
+
// bundles (a tool_result in an assistant message rejects the request);
|
|
1689
|
+
// for fresh imports the conhost importer splits at ingest time, but
|
|
1690
|
+
// already-warmed sessions hit this path. See `normalize-tool-messages.ts`.
|
|
1691
|
+
const split = splitMixedToolMessages(llmMessages);
|
|
1692
|
+
|
|
1616
1693
|
// Collapse consecutive same-participant messages for API compliance
|
|
1617
|
-
const collapsed = this.collapseConsecutiveMessages(
|
|
1694
|
+
const collapsed = this.collapseConsecutiveMessages(split);
|
|
1618
1695
|
|
|
1619
1696
|
// NO system prompt. The agent's identity is established by the head
|
|
1620
1697
|
// (the actual conversation opening — user message + agent reply that
|
|
@@ -1688,7 +1765,9 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1688
1765
|
metadata: {
|
|
1689
1766
|
chunk_message_ids: chunk.messages.map((m) => m.id),
|
|
1690
1767
|
chunk_size: chunk.messages.length,
|
|
1691
|
-
|
|
1768
|
+
prior_summary_count: priorSummaries.length,
|
|
1769
|
+
prior_summary_count_kept: keptSummaries.length,
|
|
1770
|
+
prior_summary_tokens: recallTokens,
|
|
1692
1771
|
has_doc_context: docContext !== null,
|
|
1693
1772
|
doc_context: docContext,
|
|
1694
1773
|
target_tokens: targetTokens,
|
|
@@ -1906,15 +1985,19 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1906
1985
|
llmMessages.push({ participant: m.participant, content: m.content });
|
|
1907
1986
|
}
|
|
1908
1987
|
|
|
1909
|
-
// ---- 1b. PRIOR
|
|
1910
|
-
//
|
|
1911
|
-
// aren't part of the merge tree.
|
|
1912
|
-
|
|
1913
|
-
|
|
1988
|
+
// ---- 1b. PRIOR RECALL PAIRS (chronologically before merge range) ----
|
|
1989
|
+
// The unmerged frontier of summaries whose source range is before the
|
|
1990
|
+
// merge range and which aren't part of the merge tree. Originally this
|
|
1991
|
+
// was filtered to `level === 1` (the "L1 fidelity for prior content"
|
|
1992
|
+
// intent) but at 4000+ messages that produces hundreds of L1s and
|
|
1993
|
+
// overflows the model window. Switching to the unmerged frontier
|
|
1994
|
+
// (`!mergedInto`) lets a merged L1 drop out in favour of its L2/L3
|
|
1995
|
+
// parent — the same rule used everywhere else and now in
|
|
1996
|
+
// `compressChunkHierarchical`. The cap below is the defense-in-depth.
|
|
1997
|
+
const priorSummariesAll = this.summaries
|
|
1998
|
+
.filter((s) => !s.mergedInto)
|
|
1914
1999
|
.filter((s) => {
|
|
1915
|
-
// Exclude any L1 that's an ancestor of our merge target
|
|
1916
2000
|
for (const lid of s.sourceIds) if (sourceLeafIds.has(lid)) return false;
|
|
1917
|
-
// Include only if it starts strictly before the merge range
|
|
1918
2001
|
const firstIdx = allMessages.findIndex((m) => m.id === s.sourceRange.first);
|
|
1919
2002
|
return firstIdx >= 0 && (mergeStartIdx < 0 || firstIdx < mergeStartIdx);
|
|
1920
2003
|
})
|
|
@@ -1924,12 +2007,37 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1924
2007
|
return ai - bi;
|
|
1925
2008
|
});
|
|
1926
2009
|
|
|
1927
|
-
const
|
|
1928
|
-
|
|
1929
|
-
|
|
2010
|
+
const mergeRecallBudget = this.config.compressionRecallBudgetTokens ?? 100_000;
|
|
2011
|
+
const { kept: keptPriorSummaries, keptTokens: mergeRecallTokens } = this.capRecallPairs(
|
|
2012
|
+
priorSummariesAll,
|
|
2013
|
+
mergeRecallBudget,
|
|
2014
|
+
);
|
|
2015
|
+
if (keptPriorSummaries.length < priorSummariesAll.length) {
|
|
2016
|
+
const dropped = priorSummariesAll.length - keptPriorSummaries.length;
|
|
2017
|
+
console.warn(
|
|
2018
|
+
`autobio: merge recall-pair budget capped (${keptPriorSummaries.length}/${priorSummariesAll.length} summaries kept, ` +
|
|
2019
|
+
`~${mergeRecallTokens} tokens, budget ${mergeRecallBudget}; ${dropped} oldest dropped this merge).`,
|
|
2020
|
+
);
|
|
2021
|
+
logCompressionCall({
|
|
2022
|
+
event: 'recall-budget-capped',
|
|
2023
|
+
site: 'merge',
|
|
2024
|
+
targetLevel,
|
|
2025
|
+
kept: keptPriorSummaries.length,
|
|
2026
|
+
total: priorSummariesAll.length,
|
|
2027
|
+
tokens: mergeRecallTokens,
|
|
2028
|
+
budgetTokens: mergeRecallBudget,
|
|
2029
|
+
});
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
// The full unmerged-frontier set covers what's "compressed somewhere"
|
|
2033
|
+
// for the raw-middle dedup below — a budget-dropped recall pair
|
|
2034
|
+
// doesn't make its underlying raw messages reappear.
|
|
2035
|
+
const priorSummaryMessageIds = new Set<MessageId>();
|
|
2036
|
+
for (const s of priorSummariesAll) {
|
|
2037
|
+
for (const id of s.sourceIds) priorSummaryMessageIds.add(id);
|
|
1930
2038
|
}
|
|
1931
2039
|
|
|
1932
|
-
for (const s of
|
|
2040
|
+
for (const s of keptPriorSummaries) {
|
|
1933
2041
|
llmMessages.push({
|
|
1934
2042
|
participant: 'Context Manager',
|
|
1935
2043
|
content: [{ type: 'text', text: `[CM] Recall memory ${s.id}.` }],
|
|
@@ -1941,12 +2049,12 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1941
2049
|
}
|
|
1942
2050
|
|
|
1943
2051
|
// Raw middle: any messages between the head window and the merge
|
|
1944
|
-
// range that aren't covered by a prior
|
|
2052
|
+
// range that aren't covered by a prior summary or the merge tree.
|
|
1945
2053
|
// Usually empty (chunking is contiguous).
|
|
1946
2054
|
if (mergeStartIdx >= 0) {
|
|
1947
2055
|
for (let i = headEndIdx; i < mergeStartIdx; i++) {
|
|
1948
2056
|
const m = allMessages[i];
|
|
1949
|
-
if (
|
|
2057
|
+
if (priorSummaryMessageIds.has(m.id)) continue;
|
|
1950
2058
|
if (sourceLeafIds.has(m.id)) continue;
|
|
1951
2059
|
llmMessages.push({ participant: m.participant, content: m.content });
|
|
1952
2060
|
}
|
|
@@ -2032,7 +2140,9 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2032
2140
|
}],
|
|
2033
2141
|
});
|
|
2034
2142
|
|
|
2035
|
-
|
|
2143
|
+
// Same bundled-tool-cycle defense as compressChunkHierarchical.
|
|
2144
|
+
const split = splitMixedToolMessages(llmMessages);
|
|
2145
|
+
const collapsed = this.collapseConsecutiveMessages(split);
|
|
2036
2146
|
|
|
2037
2147
|
// NO system prompt — identity is established by the head window
|
|
2038
2148
|
// (present at the start of llmMessages above) and by the prior
|
package/src/types/strategy.ts
CHANGED
|
@@ -337,6 +337,30 @@ export interface AutobiographicalConfig {
|
|
|
337
337
|
/** Token budget for L1 summaries in select() (default: 30000) */
|
|
338
338
|
l1BudgetTokens?: number;
|
|
339
339
|
|
|
340
|
+
/**
|
|
341
|
+
* Cap on the total tokens of recall-pair prior-summary content
|
|
342
|
+
* included in each LLM request that builds recall pairs:
|
|
343
|
+
*
|
|
344
|
+
* - L1 chunk compression (`compressChunkHierarchical`)
|
|
345
|
+
* - L_n merges (`executeMerge`)
|
|
346
|
+
*
|
|
347
|
+
* Defends against the case where the unmerged frontier itself is
|
|
348
|
+
* large enough to overflow the API window. Walks newest-first so
|
|
349
|
+
* proximate context survives; the kept set is then re-sorted
|
|
350
|
+
* chronologically. Each summary takes (its content tokens + 50 for
|
|
351
|
+
* the wrapping "[CM] Recall memory <id>." question).
|
|
352
|
+
*
|
|
353
|
+
* Default 100000 — chosen so that even an L_n merge (which packs
|
|
354
|
+
* recall + head + expanded target + instruction into one request)
|
|
355
|
+
* fits inside a 200k window. For larger context models or
|
|
356
|
+
* shallower hierarchies, raise this; on Sonnet/Opus default windows,
|
|
357
|
+
* the practical ceiling is roughly 130k.
|
|
358
|
+
*
|
|
359
|
+
* Set to Infinity to disable the cap and surface overflow as a
|
|
360
|
+
* 400 from the API rather than silently dropping oldest memories.
|
|
361
|
+
*/
|
|
362
|
+
compressionRecallBudgetTokens?: number;
|
|
363
|
+
|
|
340
364
|
/**
|
|
341
365
|
* When true (default), each selected summary emits as its own positioned
|
|
342
366
|
* Q/A recall pair, sorted chronologically by source range. When false,
|