@animalabs/context-manager 0.5.1 → 0.5.3
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 +73 -0
- package/dist/src/normalize-tool-messages.d.ts.map +1 -0
- package/dist/src/normalize-tool-messages.js +128 -0
- package/dist/src/normalize-tool-messages.js.map +1 -0
- package/dist/src/strategies/autobiographical.d.ts +53 -0
- package/dist/src/strategies/autobiographical.d.ts.map +1 -1
- package/dist/src/strategies/autobiographical.js +178 -43
- package/dist/src/strategies/autobiographical.js.map +1 -1
- package/dist/src/types/strategy.d.ts +17 -6
- package/dist/src/types/strategy.d.ts.map +1 -1
- package/dist/src/types/strategy.js.map +1 -1
- package/dist/test/chunker-tool-boundary.test.d.ts +12 -0
- package/dist/test/chunker-tool-boundary.test.d.ts.map +1 -0
- package/dist/test/chunker-tool-boundary.test.js +89 -0
- package/dist/test/chunker-tool-boundary.test.js.map +1 -0
- package/dist/test/compression-shape-invariants.test.d.ts +22 -0
- package/dist/test/compression-shape-invariants.test.d.ts.map +1 -0
- package/dist/test/compression-shape-invariants.test.js +312 -0
- package/dist/test/compression-shape-invariants.test.js.map +1 -0
- 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 +168 -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 +132 -0
- package/src/strategies/autobiographical.ts +194 -41
- package/src/types/strategy.ts +17 -6
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chunker invariant: a chunk must not close on a message containing a
|
|
3
|
+
* `tool_use` block, because the matching `tool_result` lives in the
|
|
4
|
+
* immediately-following user message and the Anthropic API rejects a
|
|
5
|
+
* compression request where a tool_use isn't followed by its result.
|
|
6
|
+
*
|
|
7
|
+
* Companion to `stripUnpairedToolBlocks` (the runtime safety net) — this
|
|
8
|
+
* verifies the upstream chunker behavior that avoids the situation in the
|
|
9
|
+
* first place.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, before, after } from 'node:test';
|
|
12
|
+
import assert from 'node:assert';
|
|
13
|
+
import { rmSync, existsSync } from 'node:fs';
|
|
14
|
+
import { ContextManager, AutobiographicalStrategy } from '../src/index.js';
|
|
15
|
+
const TEST_STORE_PATH = './test-chunker-tool-boundary';
|
|
16
|
+
function cleanup() {
|
|
17
|
+
if (existsSync(TEST_STORE_PATH)) {
|
|
18
|
+
rmSync(TEST_STORE_PATH, { recursive: true, force: true });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
describe('Chunker: tool cycle boundary', () => {
|
|
22
|
+
before(() => cleanup());
|
|
23
|
+
after(() => cleanup());
|
|
24
|
+
it("defers chunk closure when the last message contains a tool_use", async () => {
|
|
25
|
+
cleanup();
|
|
26
|
+
const strategy = new AutobiographicalStrategy({
|
|
27
|
+
// Very small chunk threshold so we hit close-conditions quickly.
|
|
28
|
+
targetChunkTokens: 50,
|
|
29
|
+
// Disable head/recent windows so all messages are eligible for chunking.
|
|
30
|
+
headWindowTokens: 0,
|
|
31
|
+
recentWindowTokens: 0,
|
|
32
|
+
});
|
|
33
|
+
const manager = await ContextManager.open({
|
|
34
|
+
path: TEST_STORE_PATH,
|
|
35
|
+
strategy,
|
|
36
|
+
});
|
|
37
|
+
// Build a sequence where the message that would land at the chunk
|
|
38
|
+
// boundary contains a tool_use. The matching tool_result is the next
|
|
39
|
+
// message. The chunker should defer closing to include both.
|
|
40
|
+
//
|
|
41
|
+
// Messages (~30 tokens each so we cross 50 tokens after the 2nd):
|
|
42
|
+
// 0: user text
|
|
43
|
+
// 1: agent text
|
|
44
|
+
// 2: user text
|
|
45
|
+
// 3: agent text + tool_use(id=A) <- would close here (4 msgs, >50 tok)
|
|
46
|
+
// 4: user tool_result(id=A) <- chunk extended to include this
|
|
47
|
+
// 5: agent text
|
|
48
|
+
// 6: user text
|
|
49
|
+
// ...
|
|
50
|
+
const filler = (n) => 'word '.repeat(n);
|
|
51
|
+
manager.addMessage('user', [{ type: 'text', text: filler(30) }]);
|
|
52
|
+
manager.addMessage('agent', [{ type: 'text', text: filler(30) }]);
|
|
53
|
+
manager.addMessage('user', [{ type: 'text', text: filler(30) }]);
|
|
54
|
+
manager.addMessage('agent', [
|
|
55
|
+
{ type: 'text', text: filler(20) },
|
|
56
|
+
{ type: 'tool_use', id: 'A', name: 'fn', input: {} },
|
|
57
|
+
]);
|
|
58
|
+
manager.addMessage('user', [
|
|
59
|
+
{ type: 'tool_result', toolUseId: 'A', content: 'res' },
|
|
60
|
+
]);
|
|
61
|
+
// Add more messages to ensure a second chunk forms (so the first
|
|
62
|
+
// chunk's boundary is unambiguous, not just "end of store").
|
|
63
|
+
for (let i = 0; i < 6; i++) {
|
|
64
|
+
manager.addMessage(i % 2 === 0 ? 'agent' : 'user', [
|
|
65
|
+
{ type: 'text', text: filler(30) },
|
|
66
|
+
]);
|
|
67
|
+
}
|
|
68
|
+
// Force a chunker rebuild by reading state via the strategy.
|
|
69
|
+
// ContextManager runs rebuildChunks during compile() — easier to call
|
|
70
|
+
// the internal directly.
|
|
71
|
+
const s = strategy;
|
|
72
|
+
s.rebuildChunks(manager.messageStore);
|
|
73
|
+
assert.ok(s.chunks.length >= 1, 'should have produced at least one chunk');
|
|
74
|
+
// For every closed chunk, the last message must NOT contain a tool_use.
|
|
75
|
+
for (const chunk of s.chunks) {
|
|
76
|
+
const last = chunk.messages[chunk.messages.length - 1];
|
|
77
|
+
const hasToolUse = last.content.some((b) => b.type === 'tool_use');
|
|
78
|
+
assert.ok(!hasToolUse, `chunk ending on message ${last.id} contains a trailing tool_use — boundary not deferred`);
|
|
79
|
+
}
|
|
80
|
+
// Specifically: the first chunk should have included message 4 (the
|
|
81
|
+
// tool_result), not stopped at message 3.
|
|
82
|
+
const firstChunk = s.chunks[0];
|
|
83
|
+
const firstChunkLastContent = firstChunk.messages[firstChunk.messages.length - 1].content;
|
|
84
|
+
const lastBlockIsToolResult = firstChunkLastContent.some((b) => b.type === 'tool_result');
|
|
85
|
+
assert.ok(lastBlockIsToolResult || firstChunk.messages.length >= 5, 'first chunk should have been extended to include the tool_result');
|
|
86
|
+
await manager.close();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
//# sourceMappingURL=chunker-tool-boundary.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"chunker-tool-boundary.test.js","sourceRoot":"","sources":["../../test/chunker-tool-boundary.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end shape invariants for the compression pipeline.
|
|
3
|
+
*
|
|
4
|
+
* The whole compression-bug family (5/6/7/8/9) was about the *shape* of the
|
|
5
|
+
* request sent to the Anthropic API, not the content of summaries. This
|
|
6
|
+
* suite exercises the full pipeline (message store → chunker → L1 compress
|
|
7
|
+
* → L2/L3 merges) against a mock membrane that:
|
|
8
|
+
*
|
|
9
|
+
* 1. Validates every request against the API's structural rules
|
|
10
|
+
* (tool_use/tool_result adjacency, placement in user vs. assistant
|
|
11
|
+
* turns) and throws if any rule is violated.
|
|
12
|
+
* 2. Returns a deterministic summary text at a realistic ~5:1 compression
|
|
13
|
+
* ratio so summary IDs and merge cascades behave predictably.
|
|
14
|
+
*
|
|
15
|
+
* If any of the patched call sites — `compressChunkHierarchical`,
|
|
16
|
+
* `executeMerge`, the chunker's tool-cycle-respecting close, or the
|
|
17
|
+
* runtime `splitMixedToolMessages` / `stripUnpairedToolBlocks` passes —
|
|
18
|
+
* regresses, the mock will throw with the same error shape the live API
|
|
19
|
+
* would return, and the test fails.
|
|
20
|
+
*/
|
|
21
|
+
export {};
|
|
22
|
+
//# sourceMappingURL=compression-shape-invariants.test.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"compression-shape-invariants.test.d.ts","sourceRoot":"","sources":["../../test/compression-shape-invariants.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG"}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end shape invariants for the compression pipeline.
|
|
3
|
+
*
|
|
4
|
+
* The whole compression-bug family (5/6/7/8/9) was about the *shape* of the
|
|
5
|
+
* request sent to the Anthropic API, not the content of summaries. This
|
|
6
|
+
* suite exercises the full pipeline (message store → chunker → L1 compress
|
|
7
|
+
* → L2/L3 merges) against a mock membrane that:
|
|
8
|
+
*
|
|
9
|
+
* 1. Validates every request against the API's structural rules
|
|
10
|
+
* (tool_use/tool_result adjacency, placement in user vs. assistant
|
|
11
|
+
* turns) and throws if any rule is violated.
|
|
12
|
+
* 2. Returns a deterministic summary text at a realistic ~5:1 compression
|
|
13
|
+
* ratio so summary IDs and merge cascades behave predictably.
|
|
14
|
+
*
|
|
15
|
+
* If any of the patched call sites — `compressChunkHierarchical`,
|
|
16
|
+
* `executeMerge`, the chunker's tool-cycle-respecting close, or the
|
|
17
|
+
* runtime `splitMixedToolMessages` / `stripUnpairedToolBlocks` passes —
|
|
18
|
+
* regresses, the mock will throw with the same error shape the live API
|
|
19
|
+
* would return, and the test fails.
|
|
20
|
+
*/
|
|
21
|
+
import { describe, it, before, after } from 'node:test';
|
|
22
|
+
import assert from 'node:assert';
|
|
23
|
+
import { rmSync, existsSync } from 'node:fs';
|
|
24
|
+
import { ContextManager, AutobiographicalStrategy } from '../src/index.js';
|
|
25
|
+
const TEST_STORE_PATH = './test-compression-shape';
|
|
26
|
+
function cleanup() {
|
|
27
|
+
if (existsSync(TEST_STORE_PATH)) {
|
|
28
|
+
rmSync(TEST_STORE_PATH, { recursive: true, force: true });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// API-shape validator: mirrors what the Anthropic API enforces.
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
function validateApiShape(messages) {
|
|
35
|
+
for (let i = 0; i < messages.length; i++) {
|
|
36
|
+
const msg = messages[i];
|
|
37
|
+
const isUser = msg.participant.toLowerCase() === 'user';
|
|
38
|
+
for (const block of msg.content) {
|
|
39
|
+
if (block.type === 'tool_result' && !isUser) {
|
|
40
|
+
throw new Error(`messages[${i}]: tool_result block in non-user turn (participant=${msg.participant})`);
|
|
41
|
+
}
|
|
42
|
+
if (block.type === 'tool_use') {
|
|
43
|
+
const id = block.id;
|
|
44
|
+
const next = messages[i + 1];
|
|
45
|
+
const matched = next?.content.some((b) => b.type === 'tool_result' &&
|
|
46
|
+
b.toolUseId === id);
|
|
47
|
+
if (!matched) {
|
|
48
|
+
throw new Error(`messages[${i}]: tool_use id=${id} was not followed by a matching tool_result in messages[${i + 1}]`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (block.type === 'tool_result') {
|
|
52
|
+
const tid = block.toolUseId;
|
|
53
|
+
const prev = messages[i - 1];
|
|
54
|
+
const matched = prev?.content.some((b) => b.type === 'tool_use' && b.id === tid);
|
|
55
|
+
if (!matched) {
|
|
56
|
+
throw new Error(`messages[${i}]: tool_result toolUseId=${tid} was not preceded by a matching tool_use in messages[${i - 1}]`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Mock membrane: validates shape, returns deterministic compressed text.
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
function createValidatingMembrane(compressionRatio = 5) {
|
|
66
|
+
const calls = [];
|
|
67
|
+
const membrane = {
|
|
68
|
+
complete: async (request) => {
|
|
69
|
+
validateApiShape(request.messages);
|
|
70
|
+
const inputChars = request.messages
|
|
71
|
+
.flatMap((m) => m.content)
|
|
72
|
+
.map((b) => b.text ?? '')
|
|
73
|
+
.join('').length;
|
|
74
|
+
const targetChars = Math.max(60, Math.floor(inputChars / compressionRatio));
|
|
75
|
+
const summary = `[mock summary inChars=${inputChars}] ` +
|
|
76
|
+
'x '.repeat(Math.max(0, Math.floor((targetChars - 30) / 2)));
|
|
77
|
+
calls.push({ messages: request.messages, outputChars: summary.length });
|
|
78
|
+
return {
|
|
79
|
+
content: [{ type: 'text', text: summary }],
|
|
80
|
+
usage: {
|
|
81
|
+
input_tokens: Math.ceil(inputChars / 4),
|
|
82
|
+
output_tokens: Math.ceil(summary.length / 4),
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
return { membrane, calls };
|
|
88
|
+
}
|
|
89
|
+
const t = (s) => ({ type: 'text', text: s });
|
|
90
|
+
const u = (id) => ({ type: 'tool_use', id, name: 'fn', input: {} });
|
|
91
|
+
const r = (id) => ({ type: 'tool_result', toolUseId: id, content: 'ok' });
|
|
92
|
+
async function drain(manager) {
|
|
93
|
+
// Cap iterations so a regression that fails to converge can't hang CI.
|
|
94
|
+
for (let i = 0; i < 500; i++) {
|
|
95
|
+
if (manager.isReady())
|
|
96
|
+
return;
|
|
97
|
+
await manager.tick();
|
|
98
|
+
}
|
|
99
|
+
throw new Error('drain: queue did not converge within 500 ticks');
|
|
100
|
+
}
|
|
101
|
+
describe('Compression pipeline: API shape invariants', () => {
|
|
102
|
+
before(() => cleanup());
|
|
103
|
+
after(() => cleanup());
|
|
104
|
+
it('L1 compression: chunker close lands on a tool_use without fix — must not throw', async () => {
|
|
105
|
+
cleanup();
|
|
106
|
+
const { membrane, calls } = createValidatingMembrane();
|
|
107
|
+
const strategy = new AutobiographicalStrategy({
|
|
108
|
+
targetChunkTokens: 80,
|
|
109
|
+
headWindowTokens: 0,
|
|
110
|
+
recentWindowTokens: 0,
|
|
111
|
+
hierarchical: true,
|
|
112
|
+
});
|
|
113
|
+
const manager = await ContextManager.open({
|
|
114
|
+
path: TEST_STORE_PATH,
|
|
115
|
+
strategy,
|
|
116
|
+
membrane: membrane,
|
|
117
|
+
});
|
|
118
|
+
// Force the chunker to want to close on a tool_use-bearing message:
|
|
119
|
+
// - 3 small messages (length 3, tokens ~30)
|
|
120
|
+
// - then a FAT agent message with a tool_use (length 4 → meets >=4,
|
|
121
|
+
// fat enough to push currentTokens past targetChunkTokens=80)
|
|
122
|
+
// - then the matching tool_result (would land in next chunk
|
|
123
|
+
// without the chunker-defer fix → orphan tool_use at boundary)
|
|
124
|
+
// Repeat this pattern many times so multiple boundaries are exercised.
|
|
125
|
+
const small = (n) => 'word '.repeat(n);
|
|
126
|
+
const fat = (n) => 'thinking '.repeat(n);
|
|
127
|
+
for (let i = 0; i < 12; i++) {
|
|
128
|
+
manager.addMessage('user', [t(small(8))]);
|
|
129
|
+
manager.addMessage('agent', [t(small(8))]);
|
|
130
|
+
manager.addMessage('user', [t(small(8))]);
|
|
131
|
+
manager.addMessage('agent', [t(fat(60)), u(`A${i}`)]);
|
|
132
|
+
manager.addMessage('user', [r(`A${i}`)]);
|
|
133
|
+
manager.addMessage('agent', [t(small(10))]);
|
|
134
|
+
}
|
|
135
|
+
await drain(manager);
|
|
136
|
+
assert.ok(calls.length > 0, 'expected at least one L1 compression call');
|
|
137
|
+
await manager.close();
|
|
138
|
+
});
|
|
139
|
+
it('L2/L3 merge cascade preserves shape across hundreds of summaries', async () => {
|
|
140
|
+
cleanup();
|
|
141
|
+
const { membrane, calls } = createValidatingMembrane();
|
|
142
|
+
const strategy = new AutobiographicalStrategy({
|
|
143
|
+
targetChunkTokens: 50,
|
|
144
|
+
headWindowTokens: 0,
|
|
145
|
+
recentWindowTokens: 0,
|
|
146
|
+
hierarchical: true,
|
|
147
|
+
mergeThreshold: 3, // L1→L2 every 3 L1s, L2→L3 every 9 L1s
|
|
148
|
+
});
|
|
149
|
+
const manager = await ContextManager.open({
|
|
150
|
+
path: TEST_STORE_PATH,
|
|
151
|
+
strategy,
|
|
152
|
+
membrane: membrane,
|
|
153
|
+
});
|
|
154
|
+
const filler = (n) => 'word '.repeat(n);
|
|
155
|
+
for (let i = 0; i < 80; i++) {
|
|
156
|
+
if (i % 5 === 0 && i > 0) {
|
|
157
|
+
manager.addMessage('agent', [t(filler(10)), u(`B${i}`)]);
|
|
158
|
+
manager.addMessage('user', [r(`B${i}`)]);
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
manager.addMessage(i % 2 === 0 ? 'agent' : 'user', [t(filler(15))]);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
await drain(manager);
|
|
165
|
+
// We should have hit L1 + at least one L2 merge — easiest signal is the
|
|
166
|
+
// sheer call count plus the absence of validation throws.
|
|
167
|
+
const snap = strategy.getProgressSnapshot();
|
|
168
|
+
assert.ok(snap.summaryCounts.l1 > 0, 'expected L1 summaries');
|
|
169
|
+
assert.ok(snap.summaryCounts.l2 > 0, `expected at least one L2 merge (l1=${snap.summaryCounts.l1}, l2=${snap.summaryCounts.l2})`);
|
|
170
|
+
assert.ok(calls.length >= snap.summaryCounts.l1 + snap.summaryCounts.l2);
|
|
171
|
+
await manager.close();
|
|
172
|
+
});
|
|
173
|
+
it('Bug 10: raw middle dedup correctly handles L2+ summaries (no message leak)', async () => {
|
|
174
|
+
cleanup();
|
|
175
|
+
const { membrane, calls } = createValidatingMembrane();
|
|
176
|
+
const strategy = new AutobiographicalStrategy({
|
|
177
|
+
targetChunkTokens: 40,
|
|
178
|
+
headWindowTokens: 0,
|
|
179
|
+
recentWindowTokens: 0,
|
|
180
|
+
hierarchical: true,
|
|
181
|
+
mergeThreshold: 3,
|
|
182
|
+
});
|
|
183
|
+
const manager = await ContextManager.open({
|
|
184
|
+
path: TEST_STORE_PATH,
|
|
185
|
+
strategy,
|
|
186
|
+
membrane: membrane,
|
|
187
|
+
});
|
|
188
|
+
// 100 messages → enough chunks to drive a multi-level merge cascade
|
|
189
|
+
// (L1s → L2s → L3) so executeMerge runs with L2s in its prior
|
|
190
|
+
// frontier. Each message has a distinctive marker (`RAW-N`) so we
|
|
191
|
+
// can count how many leak into requests as raw content.
|
|
192
|
+
const filler = (n) => 'word '.repeat(n);
|
|
193
|
+
for (let i = 0; i < 100; i++) {
|
|
194
|
+
manager.addMessage(i % 2 === 0 ? 'agent' : 'user', [
|
|
195
|
+
t(`RAW-${i} ${filler(8)}`),
|
|
196
|
+
]);
|
|
197
|
+
}
|
|
198
|
+
await drain(manager);
|
|
199
|
+
const snap = strategy.getProgressSnapshot();
|
|
200
|
+
assert.ok(snap.summaryCounts.l2 >= 2, `expected >=2 L2 summaries to exercise the bug (got l2=${snap.summaryCounts.l2})`);
|
|
201
|
+
// Count raw conversation messages in each request. The expansion bug
|
|
202
|
+
// allowed ~all messages to leak in (the bug report saw 256 user +
|
|
203
|
+
// 262 agent for a 4234-msg conversation). With the fix, per-request
|
|
204
|
+
// raw-message count should be bounded by the merge target's leaf
|
|
205
|
+
// size — for mergeThreshold=3 with ~3-msg L1s, that's <= ~25.
|
|
206
|
+
const RAW_MSG_LIMIT = 40;
|
|
207
|
+
for (let i = 0; i < calls.length; i++) {
|
|
208
|
+
const call = calls[i];
|
|
209
|
+
let rawCount = 0;
|
|
210
|
+
for (const m of call.messages) {
|
|
211
|
+
for (const block of m.content) {
|
|
212
|
+
const text = block.text ?? '';
|
|
213
|
+
if (text.includes('RAW-'))
|
|
214
|
+
rawCount++;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
assert.ok(rawCount <= RAW_MSG_LIMIT, `call ${i}: ${rawCount} raw messages in request — raw middle is leaking already-summarized content (Bug 10)`);
|
|
218
|
+
}
|
|
219
|
+
await manager.close();
|
|
220
|
+
});
|
|
221
|
+
it('runtime splitMixedToolMessages handles bundled cycles in source data', async () => {
|
|
222
|
+
cleanup();
|
|
223
|
+
const { membrane, calls } = createValidatingMembrane();
|
|
224
|
+
const strategy = new AutobiographicalStrategy({
|
|
225
|
+
targetChunkTokens: 60,
|
|
226
|
+
headWindowTokens: 0,
|
|
227
|
+
recentWindowTokens: 0,
|
|
228
|
+
hierarchical: true,
|
|
229
|
+
});
|
|
230
|
+
const manager = await ContextManager.open({
|
|
231
|
+
path: TEST_STORE_PATH,
|
|
232
|
+
strategy,
|
|
233
|
+
membrane: membrane,
|
|
234
|
+
});
|
|
235
|
+
// Pre-Bug-8 import shape: tool_use AND tool_result bundled into one
|
|
236
|
+
// assistant message. The runtime split has to unpack these before the
|
|
237
|
+
// request reaches the membrane.
|
|
238
|
+
for (let i = 0; i < 20; i++) {
|
|
239
|
+
manager.addMessage('user', [t('q'.repeat(60))]);
|
|
240
|
+
manager.addMessage('agent', [
|
|
241
|
+
t('a'.repeat(40)),
|
|
242
|
+
u(`C${i}`),
|
|
243
|
+
r(`C${i}`),
|
|
244
|
+
t('done'),
|
|
245
|
+
]);
|
|
246
|
+
}
|
|
247
|
+
await drain(manager);
|
|
248
|
+
assert.ok(calls.length > 0);
|
|
249
|
+
await manager.close();
|
|
250
|
+
});
|
|
251
|
+
it('orphan tool_use at the very tail (no next message ever) does not crash', async () => {
|
|
252
|
+
cleanup();
|
|
253
|
+
const { membrane, calls } = createValidatingMembrane();
|
|
254
|
+
const strategy = new AutobiographicalStrategy({
|
|
255
|
+
targetChunkTokens: 50,
|
|
256
|
+
headWindowTokens: 0,
|
|
257
|
+
recentWindowTokens: 0,
|
|
258
|
+
hierarchical: true,
|
|
259
|
+
});
|
|
260
|
+
const manager = await ContextManager.open({
|
|
261
|
+
path: TEST_STORE_PATH,
|
|
262
|
+
strategy,
|
|
263
|
+
membrane: membrane,
|
|
264
|
+
});
|
|
265
|
+
// Build a sequence that ends on a tool_use with no following tool_result.
|
|
266
|
+
// The chunker's defer rule can't help here (there's no next message);
|
|
267
|
+
// stripUnpairedToolBlocks is the safety net.
|
|
268
|
+
const filler = (n) => 'word '.repeat(n);
|
|
269
|
+
for (let i = 0; i < 12; i++) {
|
|
270
|
+
manager.addMessage(i % 2 === 0 ? 'agent' : 'user', [t(filler(20))]);
|
|
271
|
+
}
|
|
272
|
+
manager.addMessage('agent', [t(filler(10)), u('orphan')]);
|
|
273
|
+
await drain(manager);
|
|
274
|
+
assert.ok(calls.length > 0);
|
|
275
|
+
await manager.close();
|
|
276
|
+
});
|
|
277
|
+
it('adaptive resolution variant uses the same compression sites — same shape guarantees', async () => {
|
|
278
|
+
cleanup();
|
|
279
|
+
const { membrane, calls } = createValidatingMembrane();
|
|
280
|
+
const strategy = new AutobiographicalStrategy({
|
|
281
|
+
targetChunkTokens: 50,
|
|
282
|
+
headWindowTokens: 0,
|
|
283
|
+
recentWindowTokens: 0,
|
|
284
|
+
hierarchical: true,
|
|
285
|
+
adaptiveResolution: true,
|
|
286
|
+
});
|
|
287
|
+
const manager = await ContextManager.open({
|
|
288
|
+
path: TEST_STORE_PATH,
|
|
289
|
+
strategy,
|
|
290
|
+
membrane: membrane,
|
|
291
|
+
});
|
|
292
|
+
const filler = (n) => 'word '.repeat(n);
|
|
293
|
+
for (let i = 0; i < 30; i++) {
|
|
294
|
+
if (i % 4 === 0 && i > 0) {
|
|
295
|
+
manager.addMessage('agent', [t(filler(10)), u(`D${i}`)]);
|
|
296
|
+
manager.addMessage('user', [r(`D${i}`)]);
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
manager.addMessage(i % 2 === 0 ? 'agent' : 'user', [t(filler(20))]);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
// Adaptive path produces L1s lazily via the picker — trigger a compile
|
|
303
|
+
// (or two) so FoldOps run and enqueue compression work.
|
|
304
|
+
await manager.compile();
|
|
305
|
+
await drain(manager);
|
|
306
|
+
await manager.compile();
|
|
307
|
+
await drain(manager);
|
|
308
|
+
assert.ok(calls.length > 0, 'adaptive path produced no compression calls');
|
|
309
|
+
await manager.close();
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
//# sourceMappingURL=compression-shape-invariants.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"compression-shape-invariants.test.js","sourceRoot":"","sources":["../../test/compression-shape-invariants.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"normalize-tool-messages.test.d.ts","sourceRoot":"","sources":["../../test/normalize-tool-messages.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for splitMixedToolMessages — the API-shape normalizer applied
|
|
3
|
+
* in three call sites:
|
|
4
|
+
* - ContextManager.compile() (live session path)
|
|
5
|
+
* - AutobiographicalStrategy.compressChunkHierarchical (L1 compression)
|
|
6
|
+
* - AutobiographicalStrategy.executeMerge (L_n merges)
|
|
7
|
+
*
|
|
8
|
+
* The Anthropic API requires tool_result blocks in user turns immediately
|
|
9
|
+
* following their tool_use; claude.ai-exported sessions bundle the entire
|
|
10
|
+
* cycle into one assistant message, which the importer splits at ingest
|
|
11
|
+
* time but already-imported sessions still carry. This helper does the
|
|
12
|
+
* runtime fixup.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, it } from 'node:test';
|
|
15
|
+
import assert from 'node:assert';
|
|
16
|
+
import { splitMixedToolMessages, stripUnpairedToolBlocks } from '../src/index.js';
|
|
17
|
+
function mkBlocks(spec) {
|
|
18
|
+
// shorthand: 't' = text, 'u' = tool_use, 'r' = tool_result
|
|
19
|
+
return spec.split('').map((c, i) => {
|
|
20
|
+
if (c === 't')
|
|
21
|
+
return { type: 'text', text: `t${i}` };
|
|
22
|
+
if (c === 'u')
|
|
23
|
+
return { type: 'tool_use', id: `u${i}`, name: 'fn', input: {} };
|
|
24
|
+
if (c === 'r')
|
|
25
|
+
return { type: 'tool_result', toolUseId: `u${i}`, content: 'res' };
|
|
26
|
+
throw new Error(`bad char: ${c}`);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
describe('splitMixedToolMessages', () => {
|
|
30
|
+
it('passes through user messages with tool_result unchanged', () => {
|
|
31
|
+
const input = [{ participant: 'user', content: mkBlocks('r') }];
|
|
32
|
+
const out = splitMixedToolMessages(input);
|
|
33
|
+
assert.equal(out.length, 1);
|
|
34
|
+
assert.equal(out[0].participant, 'user');
|
|
35
|
+
assert.equal(out[0].content.length, 1);
|
|
36
|
+
});
|
|
37
|
+
it('passes through assistant messages with no tool_result unchanged', () => {
|
|
38
|
+
const input = [{ participant: 'agent', content: mkBlocks('tut') }];
|
|
39
|
+
const out = splitMixedToolMessages(input);
|
|
40
|
+
assert.equal(out.length, 1);
|
|
41
|
+
assert.equal(out[0].participant, 'agent');
|
|
42
|
+
assert.deepEqual(out[0].content.map((b) => b.type), ['text', 'tool_use', 'text']);
|
|
43
|
+
});
|
|
44
|
+
it('splits a bundled tool cycle into three messages', () => {
|
|
45
|
+
const input = [{ participant: 'agent', content: mkBlocks('turt') }];
|
|
46
|
+
const out = splitMixedToolMessages(input);
|
|
47
|
+
assert.equal(out.length, 3);
|
|
48
|
+
assert.equal(out[0].participant, 'agent');
|
|
49
|
+
assert.deepEqual(out[0].content.map((b) => b.type), ['text', 'tool_use']);
|
|
50
|
+
assert.equal(out[1].participant, 'user');
|
|
51
|
+
assert.deepEqual(out[1].content.map((b) => b.type), ['tool_result']);
|
|
52
|
+
assert.equal(out[2].participant, 'agent');
|
|
53
|
+
assert.deepEqual(out[2].content.map((b) => b.type), ['text']);
|
|
54
|
+
});
|
|
55
|
+
it('merges adjacent tool_results into one user message', () => {
|
|
56
|
+
const input = [{ participant: 'agent', content: mkBlocks('uurr') }];
|
|
57
|
+
const out = splitMixedToolMessages(input);
|
|
58
|
+
assert.equal(out.length, 2);
|
|
59
|
+
assert.equal(out[0].participant, 'agent');
|
|
60
|
+
assert.deepEqual(out[0].content.map((b) => b.type), ['tool_use', 'tool_use']);
|
|
61
|
+
assert.equal(out[1].participant, 'user');
|
|
62
|
+
assert.deepEqual(out[1].content.map((b) => b.type), ['tool_result', 'tool_result']);
|
|
63
|
+
});
|
|
64
|
+
it('handles alternating tool cycles in one message', () => {
|
|
65
|
+
const input = [{ participant: 'agent', content: mkBlocks('urur') }];
|
|
66
|
+
const out = splitMixedToolMessages(input);
|
|
67
|
+
assert.deepEqual(out.map((m) => m.participant), ['agent', 'user', 'agent', 'user']);
|
|
68
|
+
assert.deepEqual(out.flatMap((m) => m.content.map((b) => b.type)), [
|
|
69
|
+
'tool_use', 'tool_result', 'tool_use', 'tool_result',
|
|
70
|
+
]);
|
|
71
|
+
});
|
|
72
|
+
it('handles tool_result at the start of an assistant message', () => {
|
|
73
|
+
const input = [{ participant: 'agent', content: mkBlocks('rt') }];
|
|
74
|
+
const out = splitMixedToolMessages(input);
|
|
75
|
+
assert.equal(out.length, 2);
|
|
76
|
+
assert.equal(out[0].participant, 'user');
|
|
77
|
+
assert.deepEqual(out[0].content.map((b) => b.type), ['tool_result']);
|
|
78
|
+
assert.equal(out[1].participant, 'agent');
|
|
79
|
+
assert.deepEqual(out[1].content.map((b) => b.type), ['text']);
|
|
80
|
+
});
|
|
81
|
+
it('preserves custom agent participant names', () => {
|
|
82
|
+
const input = [{ participant: 'commander', content: mkBlocks('tur') }];
|
|
83
|
+
const out = splitMixedToolMessages(input);
|
|
84
|
+
assert.equal(out[0].participant, 'commander');
|
|
85
|
+
assert.equal(out[1].participant, 'user');
|
|
86
|
+
});
|
|
87
|
+
it('case-insensitive user check (User, USER all pass through)', () => {
|
|
88
|
+
const input = [{ participant: 'User', content: mkBlocks('r') }];
|
|
89
|
+
const out = splitMixedToolMessages(input);
|
|
90
|
+
assert.equal(out.length, 1);
|
|
91
|
+
assert.equal(out[0].participant, 'User');
|
|
92
|
+
});
|
|
93
|
+
it('walks across multiple input messages', () => {
|
|
94
|
+
const input = [
|
|
95
|
+
{ participant: 'user', content: mkBlocks('t') },
|
|
96
|
+
{ participant: 'agent', content: mkBlocks('tur') },
|
|
97
|
+
{ participant: 'user', content: mkBlocks('t') },
|
|
98
|
+
];
|
|
99
|
+
const out = splitMixedToolMessages(input);
|
|
100
|
+
// user-text, agent-text+tool_use, user-tool_result, user-text
|
|
101
|
+
assert.equal(out.length, 4);
|
|
102
|
+
assert.deepEqual(out.map((m) => m.participant), ['user', 'agent', 'user', 'user']);
|
|
103
|
+
});
|
|
104
|
+
it('returns an empty array for empty input', () => {
|
|
105
|
+
assert.deepEqual(splitMixedToolMessages([]), []);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
describe('stripUnpairedToolBlocks', () => {
|
|
109
|
+
const u = (id) => ({ type: 'tool_use', id, name: 'fn', input: {} });
|
|
110
|
+
const r = (id) => ({ type: 'tool_result', toolUseId: id, content: 'res' });
|
|
111
|
+
const t = (text) => ({ type: 'text', text });
|
|
112
|
+
it('passes through paired tool_use/tool_result unchanged', () => {
|
|
113
|
+
const input = [
|
|
114
|
+
{ participant: 'agent', content: [t('hi'), u('A')] },
|
|
115
|
+
{ participant: 'user', content: [r('A')] },
|
|
116
|
+
];
|
|
117
|
+
const out = stripUnpairedToolBlocks(input);
|
|
118
|
+
assert.deepEqual(out.map((m) => m.content.map((b) => b.type)), [
|
|
119
|
+
['text', 'tool_use'],
|
|
120
|
+
['tool_result'],
|
|
121
|
+
]);
|
|
122
|
+
});
|
|
123
|
+
it('strips a tool_use whose tool_result is absent (chunk-boundary tail)', () => {
|
|
124
|
+
const input = [
|
|
125
|
+
{ participant: 'agent', content: [t('preamble'), u('A')] },
|
|
126
|
+
];
|
|
127
|
+
const out = stripUnpairedToolBlocks(input);
|
|
128
|
+
assert.equal(out.length, 1);
|
|
129
|
+
assert.deepEqual(out[0].content.map((b) => b.type), ['text']);
|
|
130
|
+
});
|
|
131
|
+
it('strips a tool_result whose tool_use is absent (chunk-boundary head)', () => {
|
|
132
|
+
const input = [
|
|
133
|
+
{ participant: 'user', content: [r('A')] },
|
|
134
|
+
{ participant: 'agent', content: [t('reply')] },
|
|
135
|
+
];
|
|
136
|
+
const out = stripUnpairedToolBlocks(input);
|
|
137
|
+
assert.equal(out.length, 2);
|
|
138
|
+
assert.deepEqual(out[0].content.map((b) => b.type), ['text']);
|
|
139
|
+
assert.equal(out[0].content[0].text, '[tool call omitted]');
|
|
140
|
+
assert.deepEqual(out[1].content.map((b) => b.type), ['text']);
|
|
141
|
+
});
|
|
142
|
+
it('keeps paired blocks and strips only the unpaired ones in a mixed message', () => {
|
|
143
|
+
const input = [
|
|
144
|
+
{ participant: 'agent', content: [u('A'), u('B'), u('C')] },
|
|
145
|
+
{ participant: 'user', content: [r('B'), r('C')] },
|
|
146
|
+
];
|
|
147
|
+
const out = stripUnpairedToolBlocks(input);
|
|
148
|
+
assert.deepEqual(out[0].content.map((b) => b.id ?? b.type), ['B', 'C']);
|
|
149
|
+
assert.deepEqual(out[1].content.map((b) => b.toolUseId ?? b.type), ['B', 'C']);
|
|
150
|
+
});
|
|
151
|
+
it('replaces empty content with a placeholder text block', () => {
|
|
152
|
+
const input = [{ participant: 'agent', content: [u('A')] }];
|
|
153
|
+
const out = stripUnpairedToolBlocks(input);
|
|
154
|
+
assert.equal(out.length, 1);
|
|
155
|
+
assert.equal(out[0].content.length, 1);
|
|
156
|
+
assert.equal(out[0].content[0].type, 'text');
|
|
157
|
+
assert.match(out[0].content[0].text, /tool call omitted/);
|
|
158
|
+
});
|
|
159
|
+
it('returns an empty array for empty input', () => {
|
|
160
|
+
assert.deepEqual(stripUnpairedToolBlocks([]), []);
|
|
161
|
+
});
|
|
162
|
+
it('preserves reference identity for unchanged messages', () => {
|
|
163
|
+
const msg = { participant: 'user', content: [t('hello')] };
|
|
164
|
+
const out = stripUnpairedToolBlocks([msg]);
|
|
165
|
+
assert.strictEqual(out[0].content, msg.content);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
//# sourceMappingURL=normalize-tool-messages.test.js.map
|