@animalabs/membrane 0.5.80 → 0.5.82
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/cache-wire-receipt.d.ts +13 -0
- package/dist/cache-wire-receipt.d.ts.map +1 -0
- package/dist/cache-wire-receipt.js +108 -0
- package/dist/cache-wire-receipt.js.map +1 -0
- package/dist/context/index.d.ts +1 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +1 -1
- package/dist/context/index.js.map +1 -1
- package/dist/context/process.d.ts +3 -1
- package/dist/context/process.d.ts.map +1 -1
- package/dist/context/process.js +230 -49
- package/dist/context/process.js.map +1 -1
- package/dist/context/types.d.ts +30 -0
- package/dist/context/types.d.ts.map +1 -1
- package/dist/context/types.js +16 -0
- package/dist/context/types.js.map +1 -1
- package/dist/floating-cache-marker.test.js +21 -0
- package/dist/floating-cache-marker.test.js.map +1 -1
- package/dist/formatters/anthropic-xml.d.ts +2 -0
- package/dist/formatters/anthropic-xml.d.ts.map +1 -1
- package/dist/formatters/anthropic-xml.js +64 -23
- package/dist/formatters/anthropic-xml.js.map +1 -1
- package/dist/formatters/native.d.ts.map +1 -1
- package/dist/formatters/native.js +16 -3
- package/dist/formatters/native.js.map +1 -1
- package/dist/formatters/normalize-tool-pairs.d.ts +7 -0
- package/dist/formatters/normalize-tool-pairs.d.ts.map +1 -1
- package/dist/formatters/normalize-tool-pairs.js +45 -0
- package/dist/formatters/normalize-tool-pairs.js.map +1 -1
- package/dist/formatters/types.d.ts +31 -0
- package/dist/formatters/types.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/membrane.d.ts +137 -8
- package/dist/membrane.d.ts.map +1 -1
- package/dist/membrane.js +823 -310
- package/dist/membrane.js.map +1 -1
- package/dist/providers/anthropic.d.ts +37 -0
- package/dist/providers/anthropic.d.ts.map +1 -1
- package/dist/providers/anthropic.js +142 -25
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/bedrock.d.ts +8 -0
- package/dist/providers/bedrock.d.ts.map +1 -1
- package/dist/providers/bedrock.js +24 -1
- package/dist/providers/bedrock.js.map +1 -1
- package/dist/providers/gemini.d.ts +11 -0
- package/dist/providers/gemini.d.ts.map +1 -1
- package/dist/providers/gemini.js +104 -82
- package/dist/providers/gemini.js.map +1 -1
- package/dist/providers/index.d.ts +1 -1
- package/dist/providers/index.d.ts.map +1 -1
- package/dist/providers/index.js.map +1 -1
- package/dist/providers/mock.d.ts +2 -0
- package/dist/providers/mock.d.ts.map +1 -1
- package/dist/providers/mock.js +2 -0
- package/dist/providers/mock.js.map +1 -1
- package/dist/providers/openai-compatible.d.ts +7 -0
- package/dist/providers/openai-compatible.d.ts.map +1 -1
- package/dist/providers/openai-compatible.js +99 -48
- package/dist/providers/openai-compatible.js.map +1 -1
- package/dist/providers/openai-completions.d.ts +5 -0
- package/dist/providers/openai-completions.d.ts.map +1 -1
- package/dist/providers/openai-completions.js +107 -44
- package/dist/providers/openai-completions.js.map +1 -1
- package/dist/providers/openai-responses-api.d.ts +16 -0
- package/dist/providers/openai-responses-api.d.ts.map +1 -1
- package/dist/providers/openai-responses-api.js +38 -10
- package/dist/providers/openai-responses-api.js.map +1 -1
- package/dist/providers/openai-responses.d.ts +2 -0
- package/dist/providers/openai-responses.d.ts.map +1 -1
- package/dist/providers/openai-responses.js +8 -1
- package/dist/providers/openai-responses.js.map +1 -1
- package/dist/providers/openai.d.ts +7 -0
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +92 -44
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/openrouter.d.ts +9 -0
- package/dist/providers/openrouter.d.ts.map +1 -1
- package/dist/providers/openrouter.js +118 -62
- package/dist/providers/openrouter.js.map +1 -1
- package/dist/providers/utils.d.ts +74 -2
- package/dist/providers/utils.d.ts.map +1 -1
- package/dist/providers/utils.js +184 -3
- package/dist/providers/utils.js.map +1 -1
- package/dist/registry/default-pricing.d.ts +22 -0
- package/dist/registry/default-pricing.d.ts.map +1 -1
- package/dist/registry/default-pricing.js +162 -28
- package/dist/registry/default-pricing.js.map +1 -1
- package/dist/types/content.d.ts +13 -0
- package/dist/types/content.d.ts.map +1 -1
- package/dist/types/content.js.map +1 -1
- package/dist/types/errors.d.ts +15 -0
- package/dist/types/errors.d.ts.map +1 -1
- package/dist/types/errors.js +26 -0
- package/dist/types/errors.js.map +1 -1
- package/dist/types/index.d.ts +3 -3
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +1 -1
- package/dist/types/index.js.map +1 -1
- package/dist/types/provider.d.ts +50 -0
- package/dist/types/provider.d.ts.map +1 -1
- package/dist/types/request.d.ts +6 -0
- package/dist/types/request.d.ts.map +1 -1
- package/dist/types/response.d.ts +85 -4
- package/dist/types/response.d.ts.map +1 -1
- package/dist/types/response.js.map +1 -1
- package/dist/types/streaming.d.ts +6 -1
- package/dist/types/streaming.d.ts.map +1 -1
- package/dist/utils/cache-marker-budget.d.ts +91 -0
- package/dist/utils/cache-marker-budget.d.ts.map +1 -0
- package/dist/utils/cache-marker-budget.js +208 -0
- package/dist/utils/cache-marker-budget.js.map +1 -0
- package/dist/utils/cost.d.ts +22 -0
- package/dist/utils/cost.d.ts.map +1 -1
- package/dist/utils/cost.js +55 -0
- package/dist/utils/cost.js.map +1 -1
- package/dist/utils/thinking-carriers.d.ts +80 -0
- package/dist/utils/thinking-carriers.d.ts.map +1 -0
- package/dist/utils/thinking-carriers.js +115 -0
- package/dist/utils/thinking-carriers.js.map +1 -0
- package/dist/utils/tool-parser.d.ts +24 -0
- package/dist/utils/tool-parser.d.ts.map +1 -1
- package/dist/utils/tool-parser.js +367 -161
- package/dist/utils/tool-parser.js.map +1 -1
- package/dist/utils/usage.d.ts +71 -0
- package/dist/utils/usage.d.ts.map +1 -0
- package/dist/utils/usage.js +161 -0
- package/dist/utils/usage.js.map +1 -0
- package/dist/yielding-stream.d.ts +8 -0
- package/dist/yielding-stream.d.ts.map +1 -1
- package/dist/yielding-stream.js +49 -3
- package/dist/yielding-stream.js.map +1 -1
- package/package.json +1 -1
- package/src/cache-wire-receipt.ts +125 -0
- package/src/context/index.ts +1 -0
- package/src/context/process.ts +305 -59
- package/src/context/types.ts +38 -0
- package/src/floating-cache-marker.test.ts +24 -0
- package/src/formatters/anthropic-xml.ts +66 -23
- package/src/formatters/native.ts +23 -2
- package/src/formatters/normalize-tool-pairs.ts +49 -0
- package/src/formatters/types.ts +35 -0
- package/src/index.ts +6 -0
- package/src/membrane.ts +974 -313
- package/src/providers/anthropic.ts +173 -19
- package/src/providers/bedrock.ts +25 -1
- package/src/providers/gemini.ts +125 -87
- package/src/providers/index.ts +1 -0
- package/src/providers/mock.ts +3 -0
- package/src/providers/openai-compatible.ts +102 -45
- package/src/providers/openai-completions.ts +112 -46
- package/src/providers/openai-responses-api.ts +45 -15
- package/src/providers/openai-responses.ts +8 -1
- package/src/providers/openai.ts +95 -42
- package/src/providers/openrouter.ts +125 -63
- package/src/providers/utils.ts +227 -6
- package/src/registry/default-pricing.ts +165 -28
- package/src/types/content.ts +14 -0
- package/src/types/errors.ts +28 -0
- package/src/types/index.ts +6 -0
- package/src/types/provider.ts +55 -0
- package/src/types/request.ts +7 -0
- package/src/types/response.ts +109 -8
- package/src/types/streaming.ts +8 -2
- package/src/utils/cache-marker-budget.ts +237 -0
- package/src/utils/cost.ts +64 -0
- package/src/utils/thinking-carriers.ts +125 -0
- package/src/utils/tool-parser.ts +473 -174
- package/src/utils/usage.ts +184 -0
- package/src/yielding-stream.ts +54 -3
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export interface CacheWireMarkerReceipt {
|
|
4
|
+
ordinal: number;
|
|
5
|
+
prefixHash: string;
|
|
6
|
+
estimatedOffset: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface CacheWireReceipt {
|
|
10
|
+
requestHash: string;
|
|
11
|
+
markers: CacheWireMarkerReceipt[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Hash the exact post-format/post-hook provider request and every marked
|
|
15
|
+
* prefix. Token offsets are estimates; provider usage reconciles them later. */
|
|
16
|
+
export function computeCacheWireReceipt(rawRequest: unknown): CacheWireReceipt {
|
|
17
|
+
const requestHash = sha(stableStringify(rawRequest));
|
|
18
|
+
const blocks = flattenWireBlocks(rawRequest);
|
|
19
|
+
const prefix: unknown[] = [];
|
|
20
|
+
const markers: CacheWireMarkerReceipt[] = [];
|
|
21
|
+
let chars = 0;
|
|
22
|
+
for (const unit of blocks) {
|
|
23
|
+
prefix.push(unit.identity);
|
|
24
|
+
chars += unit.estimatedChars;
|
|
25
|
+
if (unit.marked) {
|
|
26
|
+
markers.push({
|
|
27
|
+
ordinal: markers.length,
|
|
28
|
+
prefixHash: sha(stableStringify(prefix)),
|
|
29
|
+
estimatedOffset: Math.ceil(chars / 4),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return { requestHash, markers };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface ReceiptUnit {
|
|
37
|
+
identity: unknown;
|
|
38
|
+
marked: boolean;
|
|
39
|
+
estimatedChars: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function flattenWireBlocks(rawRequest: unknown): ReceiptUnit[] {
|
|
43
|
+
if (!rawRequest || typeof rawRequest !== 'object') {
|
|
44
|
+
return [{ identity: rawRequest, marked: false, estimatedChars: stableStringify(rawRequest).length }];
|
|
45
|
+
}
|
|
46
|
+
const request = rawRequest as Record<string, unknown>;
|
|
47
|
+
const out: ReceiptUnit[] = [];
|
|
48
|
+
if (Array.isArray(request.tools)) {
|
|
49
|
+
request.tools.forEach((tool, index) => out.push({
|
|
50
|
+
identity: { surface: 'tool', index, tool },
|
|
51
|
+
marked: hasCacheControl(tool),
|
|
52
|
+
estimatedChars: stableStringify(tool).length,
|
|
53
|
+
}));
|
|
54
|
+
} else if (request.tools !== undefined) {
|
|
55
|
+
out.push({
|
|
56
|
+
identity: { surface: 'tools', value: request.tools },
|
|
57
|
+
marked: hasCacheControl(request.tools),
|
|
58
|
+
estimatedChars: stableStringify(request.tools).length,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
if (request.system !== undefined) {
|
|
62
|
+
const system = request.system;
|
|
63
|
+
if (Array.isArray(system)) {
|
|
64
|
+
system.forEach((block, index) => out.push({
|
|
65
|
+
identity: { surface: 'system', index, block },
|
|
66
|
+
marked: hasCacheControl(block),
|
|
67
|
+
estimatedChars: stableStringify(block).length,
|
|
68
|
+
}));
|
|
69
|
+
} else {
|
|
70
|
+
out.push({
|
|
71
|
+
identity: { surface: 'system', value: system },
|
|
72
|
+
marked: hasCacheControl(system),
|
|
73
|
+
estimatedChars: stableStringify(system).length,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (Array.isArray(request.messages)) {
|
|
78
|
+
request.messages.forEach((message, messageIndex) => {
|
|
79
|
+
if (!message || typeof message !== 'object') {
|
|
80
|
+
out.push({
|
|
81
|
+
identity: { surface: 'message', messageIndex, value: message },
|
|
82
|
+
marked: false,
|
|
83
|
+
estimatedChars: stableStringify(message).length,
|
|
84
|
+
});
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const record = message as Record<string, unknown>;
|
|
88
|
+
const content = (message as Record<string, unknown>).content;
|
|
89
|
+
if (Array.isArray(content)) {
|
|
90
|
+
content.forEach((block, blockIndex) => out.push({
|
|
91
|
+
identity: {
|
|
92
|
+
surface: 'message-block', messageIndex, blockIndex,
|
|
93
|
+
role: record.role, block,
|
|
94
|
+
},
|
|
95
|
+
marked: hasCacheControl(block),
|
|
96
|
+
estimatedChars: stableStringify({ role: record.role, content: [block] }).length,
|
|
97
|
+
}));
|
|
98
|
+
} else {
|
|
99
|
+
out.push({
|
|
100
|
+
identity: { surface: 'message', messageIndex, role: record.role, content },
|
|
101
|
+
marked: hasCacheControl(message),
|
|
102
|
+
estimatedChars: stableStringify(message).length,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function hasCacheControl(value: unknown): boolean {
|
|
111
|
+
return Boolean(value && typeof value === 'object' && (value as Record<string, unknown>).cache_control);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function stableStringify(value: unknown): string {
|
|
115
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
|
|
116
|
+
if (value && typeof value === 'object') {
|
|
117
|
+
const record = value as Record<string, unknown>;
|
|
118
|
+
return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`).join(',')}}`;
|
|
119
|
+
}
|
|
120
|
+
return JSON.stringify(value) ?? 'null';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function sha(value: string): string {
|
|
124
|
+
return createHash('sha256').update(value).digest('hex');
|
|
125
|
+
}
|
package/src/context/index.ts
CHANGED
package/src/context/process.ts
CHANGED
|
@@ -17,8 +17,26 @@ import {
|
|
|
17
17
|
createInitialState,
|
|
18
18
|
defaultTokenEstimator,
|
|
19
19
|
DEFAULT_CONTEXT_CONFIG,
|
|
20
|
+
MembraneContextIdentityError,
|
|
20
21
|
} from './types.js';
|
|
21
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Ceiling on markers this module places, regardless of `cache.points`.
|
|
25
|
+
*
|
|
26
|
+
* Anthropic accepts at most 4 `cache_control` blocks per request, and the
|
|
27
|
+
* request builders spend from that same budget (a system/tools fallback
|
|
28
|
+
* block, a `contextPrefix` block when that option is set, the floating
|
|
29
|
+
* tool-loop marker). Nothing reconciles those spends against
|
|
30
|
+
* `cache.points`, so the module keeps one slot free rather than risk a 400
|
|
31
|
+
* on the default XML path, which always marks the system block. A caller
|
|
32
|
+
* combining this module with MORE than one formatter spend (e.g. system
|
|
33
|
+
* prompt AND `contextPrefix`) can still exceed the provider budget: the
|
|
34
|
+
* module cannot see the formatter's choices from here, which is why the
|
|
35
|
+
* request-side wire clamp (the cache-marker budget PR) is the layer that
|
|
36
|
+
* makes the ceiling safe by construction rather than by convention.
|
|
37
|
+
*/
|
|
38
|
+
const MAX_MODULE_CACHE_POINTS = 3;
|
|
39
|
+
|
|
22
40
|
// ============================================================================
|
|
23
41
|
// Main Entry Point
|
|
24
42
|
// ============================================================================
|
|
@@ -48,6 +66,10 @@ export async function processContext(
|
|
|
48
66
|
const contextConfig = mergeConfig(input.context);
|
|
49
67
|
const tokenEstimator = contextConfig.tokenEstimator ?? defaultTokenEstimator;
|
|
50
68
|
|
|
69
|
+
// Stable identity is a precondition, not a nicety: without it every call
|
|
70
|
+
// looks like a new conversation and rolling/caching silently stop working.
|
|
71
|
+
assertStableMessageIds(input.messages);
|
|
72
|
+
|
|
51
73
|
// Initialize or continue state
|
|
52
74
|
let currentState = state ?? createInitialState();
|
|
53
75
|
|
|
@@ -87,12 +109,16 @@ export async function processContext(
|
|
|
87
109
|
messageTokens,
|
|
88
110
|
rollDecision.targetTokens,
|
|
89
111
|
rollDecision.targetMessages,
|
|
90
|
-
contextConfig
|
|
112
|
+
contextConfig,
|
|
113
|
+
rollDecision.targetCharacters
|
|
91
114
|
);
|
|
92
115
|
|
|
93
116
|
keptMessages = truncateResult.kept.map(m => m.message);
|
|
94
117
|
messagesDropped = truncateResult.dropped;
|
|
95
|
-
|
|
118
|
+
// A roll that dropped nothing is not a roll: reporting it as one both
|
|
119
|
+
// lies to the caller and resets the roll counters every call, which is
|
|
120
|
+
// exactly when threshold rolling is needed most.
|
|
121
|
+
didRoll = messagesDropped > 0;
|
|
96
122
|
hardLimitHit = rollDecision.reason === 'hard_limit';
|
|
97
123
|
}
|
|
98
124
|
|
|
@@ -104,6 +130,15 @@ export async function processContext(
|
|
|
104
130
|
}));
|
|
105
131
|
const keptTotalTokens = keptTokens.reduce((sum, m) => sum + m.tokens, 0);
|
|
106
132
|
|
|
133
|
+
// Re-assert the hard limits against the truncated window. The window is
|
|
134
|
+
// floored at one message, so a single oversize message survives every
|
|
135
|
+
// truncation - the caller is told rather than handed an empty array.
|
|
136
|
+
const residualOverflow = measureResidualOverflow(
|
|
137
|
+
keptMessages,
|
|
138
|
+
keptTotalTokens,
|
|
139
|
+
contextConfig
|
|
140
|
+
);
|
|
141
|
+
|
|
107
142
|
// Place cache markers
|
|
108
143
|
const cacheMarkers = placeCacheMarkers(
|
|
109
144
|
keptMessages,
|
|
@@ -168,6 +203,7 @@ export async function processContext(
|
|
|
168
203
|
totalTokens: keptTotalTokens,
|
|
169
204
|
hardLimitHit,
|
|
170
205
|
cachedStartMessageId,
|
|
206
|
+
...(residualOverflow ? { residualOverflow } : {}),
|
|
171
207
|
};
|
|
172
208
|
|
|
173
209
|
return { response, state: newState, info };
|
|
@@ -178,7 +214,13 @@ export async function processContext(
|
|
|
178
214
|
// ============================================================================
|
|
179
215
|
|
|
180
216
|
function mergeConfig(config: ContextConfig): ContextConfig {
|
|
217
|
+
// The caller's config is the base, so a top-level field is carried through
|
|
218
|
+
// by default and only the three sub-objects that have defaults are merged.
|
|
219
|
+
// Enumerating the survivors instead silently dropped assistantParticipant:
|
|
220
|
+
// every helper test passed a config straight in, so the loss was invisible
|
|
221
|
+
// until an end-to-end call classified the configured assistant as a user.
|
|
181
222
|
return {
|
|
223
|
+
...config,
|
|
182
224
|
rolling: {
|
|
183
225
|
...DEFAULT_CONTEXT_CONFIG.rolling,
|
|
184
226
|
...config.rolling,
|
|
@@ -191,7 +233,6 @@ function mergeConfig(config: ContextConfig): ContextConfig {
|
|
|
191
233
|
...DEFAULT_CONTEXT_CONFIG.cache,
|
|
192
234
|
...config.cache,
|
|
193
235
|
},
|
|
194
|
-
tokenEstimator: config.tokenEstimator,
|
|
195
236
|
};
|
|
196
237
|
}
|
|
197
238
|
|
|
@@ -199,6 +240,61 @@ function getMessageId(message: NormalizedMessage): string {
|
|
|
199
240
|
return message.metadata?.sourceId ?? `msg-${Math.random().toString(36).slice(2)}`;
|
|
200
241
|
}
|
|
201
242
|
|
|
243
|
+
function assertStableMessageIds(messages: NormalizedMessage[]): void {
|
|
244
|
+
const messageIndicesWithoutSourceId: number[] = [];
|
|
245
|
+
|
|
246
|
+
messages.forEach((message, index) => {
|
|
247
|
+
const sourceId = message.metadata?.sourceId;
|
|
248
|
+
if (typeof sourceId !== 'string' || sourceId.length === 0) {
|
|
249
|
+
messageIndicesWithoutSourceId.push(index);
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
if (messageIndicesWithoutSourceId.length === 0) {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const shown = messageIndicesWithoutSourceId.slice(0, 10).join(', ');
|
|
258
|
+
const ellipsis = messageIndicesWithoutSourceId.length > 10 ? ', ...' : '';
|
|
259
|
+
throw new MembraneContextIdentityError(
|
|
260
|
+
`processContext requires stable message identity: ` +
|
|
261
|
+
`${messageIndicesWithoutSourceId.length} of ${messages.length} messages carry no ` +
|
|
262
|
+
`metadata.sourceId (indices ${shown}${ellipsis}). Without it every call is detected ` +
|
|
263
|
+
`as a new conversation, so the roll threshold never accumulates, cache markers never ` +
|
|
264
|
+
`stay stable, and cachedStartMessageId is meaningless. Populate metadata.sourceId ` +
|
|
265
|
+
`with the originating system's message id.`,
|
|
266
|
+
messageIndicesWithoutSourceId
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function measureResidualOverflow(
|
|
271
|
+
keptMessages: NormalizedMessage[],
|
|
272
|
+
keptTotalTokens: number,
|
|
273
|
+
config: ContextConfig
|
|
274
|
+
): ContextInfo['residualOverflow'] {
|
|
275
|
+
const limits = config.limits;
|
|
276
|
+
if (!limits) {
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (limits.maxCharacters) {
|
|
281
|
+
const keptCharacters = calculateCharacters(keptMessages);
|
|
282
|
+
if (keptCharacters > limits.maxCharacters) {
|
|
283
|
+
return { unit: 'characters', limit: limits.maxCharacters, actual: keptCharacters };
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (limits.maxTokens && keptTotalTokens > limits.maxTokens) {
|
|
288
|
+
return { unit: 'tokens', limit: limits.maxTokens, actual: keptTotalTokens };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (limits.maxMessages && keptMessages.length > limits.maxMessages) {
|
|
292
|
+
return { unit: 'messages', limit: limits.maxMessages, actual: keptMessages.length };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return undefined;
|
|
296
|
+
}
|
|
297
|
+
|
|
202
298
|
function detectDiscontinuity(
|
|
203
299
|
messages: NormalizedMessage[],
|
|
204
300
|
state: ContextState
|
|
@@ -237,6 +333,8 @@ export interface RollDecision {
|
|
|
237
333
|
reason?: 'threshold' | 'grace_exceeded' | 'hard_limit';
|
|
238
334
|
targetTokens?: number;
|
|
239
335
|
targetMessages?: number;
|
|
336
|
+
/** Character budget the kept window must fit (set by the maxCharacters limit). */
|
|
337
|
+
targetCharacters?: number;
|
|
240
338
|
enteredGrace: boolean;
|
|
241
339
|
}
|
|
242
340
|
|
|
@@ -261,6 +359,7 @@ export function shouldRoll(
|
|
|
261
359
|
reason: 'hard_limit',
|
|
262
360
|
targetTokens: limits.maxTokens,
|
|
263
361
|
targetMessages: limits.maxMessages,
|
|
362
|
+
targetCharacters: limits.maxCharacters,
|
|
264
363
|
enteredGrace: false,
|
|
265
364
|
};
|
|
266
365
|
}
|
|
@@ -271,6 +370,7 @@ export function shouldRoll(
|
|
|
271
370
|
reason: 'hard_limit',
|
|
272
371
|
targetTokens: limits.maxTokens,
|
|
273
372
|
targetMessages: limits.maxMessages,
|
|
373
|
+
targetCharacters: limits.maxCharacters,
|
|
274
374
|
enteredGrace: false,
|
|
275
375
|
};
|
|
276
376
|
}
|
|
@@ -281,12 +381,16 @@ export function shouldRoll(
|
|
|
281
381
|
reason: 'hard_limit',
|
|
282
382
|
targetTokens: limits.maxTokens,
|
|
283
383
|
targetMessages: limits.maxMessages,
|
|
384
|
+
targetCharacters: limits.maxCharacters,
|
|
284
385
|
enteredGrace: false,
|
|
285
386
|
};
|
|
286
387
|
}
|
|
287
388
|
|
|
288
|
-
// Check rolling threshold
|
|
289
|
-
|
|
389
|
+
// Check rolling threshold against the MEASURED window. state.messagesSinceRoll
|
|
390
|
+
// counts calls (not messages) and state.tokensSinceRoll re-adds the whole
|
|
391
|
+
// window every call, so both cross any threshold as a function of call count
|
|
392
|
+
// alone; they stay as telemetry and no longer decide the roll.
|
|
393
|
+
const current = unit === 'messages' ? messageCount : totalTokens;
|
|
290
394
|
|
|
291
395
|
if (current >= maxThreshold) {
|
|
292
396
|
// Exceeded grace, must roll
|
|
@@ -323,54 +427,162 @@ export function truncateMessages(
|
|
|
323
427
|
messages: MessageWithTokens[],
|
|
324
428
|
targetTokens?: number,
|
|
325
429
|
targetMessages?: number,
|
|
326
|
-
config?: ContextConfig
|
|
430
|
+
config?: ContextConfig,
|
|
431
|
+
targetCharacters?: number
|
|
327
432
|
): { kept: MessageWithTokens[]; dropped: number } {
|
|
328
|
-
// Truncate from the beginning, keeping most recent
|
|
433
|
+
// Truncate from the beginning, keeping most recent.
|
|
434
|
+
// Every supplied target contributes a candidate start index; the window has
|
|
435
|
+
// to satisfy all of them, so the deepest cut wins.
|
|
329
436
|
|
|
330
|
-
if (
|
|
331
|
-
|
|
332
|
-
return {
|
|
333
|
-
kept: messages.slice(startIdx),
|
|
334
|
-
dropped: startIdx,
|
|
335
|
-
};
|
|
437
|
+
if (messages.length === 0) {
|
|
438
|
+
return { kept: messages, dropped: 0 };
|
|
336
439
|
}
|
|
337
440
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
441
|
+
const candidateStartIndices: number[] = [];
|
|
442
|
+
|
|
443
|
+
if (targetMessages !== undefined && messages.length > targetMessages) {
|
|
444
|
+
candidateStartIndices.push(messages.length - targetMessages);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (targetTokens !== undefined) {
|
|
448
|
+
candidateStartIndices.push(
|
|
449
|
+
startIndexForBudget(messages, targetTokens, m => m.tokens)
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (targetCharacters !== undefined) {
|
|
454
|
+
candidateStartIndices.push(
|
|
455
|
+
startIndexForBudget(messages, targetCharacters, m => calculateCharacters([m.message]))
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (candidateStartIndices.length === 0) {
|
|
460
|
+
// Default: use buffer from config
|
|
461
|
+
const buffer = config?.rolling.buffer ?? 20;
|
|
462
|
+
const unit = config?.rolling.unit ?? 'messages';
|
|
341
463
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
startIdx = i + 1;
|
|
347
|
-
break;
|
|
464
|
+
if (unit === 'messages') {
|
|
465
|
+
const targetCount = Math.max(buffer * 2, messages.length - buffer);
|
|
466
|
+
if (messages.length > targetCount) {
|
|
467
|
+
candidateStartIndices.push(messages.length - targetCount);
|
|
348
468
|
}
|
|
349
|
-
startIdx = i;
|
|
350
469
|
}
|
|
351
|
-
|
|
352
|
-
return {
|
|
353
|
-
kept: messages.slice(startIdx),
|
|
354
|
-
dropped: startIdx,
|
|
355
|
-
};
|
|
356
470
|
}
|
|
357
471
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
472
|
+
if (candidateStartIndices.length === 0) {
|
|
473
|
+
return { kept: messages, dropped: 0 };
|
|
474
|
+
}
|
|
361
475
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
476
|
+
const startIdx = resolveWindowStart(messages, Math.max(0, ...candidateStartIndices));
|
|
477
|
+
|
|
478
|
+
return {
|
|
479
|
+
kept: messages.slice(startIdx),
|
|
480
|
+
dropped: startIdx,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Walk backwards from the newest message, accumulating cost, and return the
|
|
486
|
+
* first index whose window fits the budget. Mirrors the original token walk:
|
|
487
|
+
* the message that tips the sum past the budget is excluded.
|
|
488
|
+
*/
|
|
489
|
+
function startIndexForBudget(
|
|
490
|
+
messages: MessageWithTokens[],
|
|
491
|
+
budget: number,
|
|
492
|
+
costOf: (message: MessageWithTokens) => number
|
|
493
|
+
): number {
|
|
494
|
+
let sum = 0;
|
|
495
|
+
let startIdx = messages.length;
|
|
496
|
+
|
|
497
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
498
|
+
sum += costOf(messages[i]!);
|
|
499
|
+
if (sum > budget) {
|
|
500
|
+
return i + 1;
|
|
370
501
|
}
|
|
502
|
+
startIdx = i;
|
|
371
503
|
}
|
|
372
504
|
|
|
373
|
-
return
|
|
505
|
+
return startIdx;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Resolve the index the kept window actually starts at: the deepest cut at or
|
|
510
|
+
* after the requested one whose window is both non-empty and free of
|
|
511
|
+
* tool_results whose tool_use was dropped.
|
|
512
|
+
*
|
|
513
|
+
* Snapping forward and flooring at one message used to be two independent
|
|
514
|
+
* steps that could undo each other. When the newest message is a tool_result,
|
|
515
|
+
* the forward snap runs off the end of the array and the floor pulls the index
|
|
516
|
+
* back onto that same orphan - so truncating a final tool cycle to one message
|
|
517
|
+
* kept exactly the result, which is what the snap exists to prevent. A cut that
|
|
518
|
+
* cannot go forward now walks BACKWARD to the call that opens the final cycle:
|
|
519
|
+
* the window overshoots the requested size (reported through
|
|
520
|
+
* ContextInfo.residualOverflow) rather than shipping a corrupt transcript.
|
|
521
|
+
*/
|
|
522
|
+
function resolveWindowStart(
|
|
523
|
+
messages: MessageWithTokens[],
|
|
524
|
+
desiredStartIdx: number
|
|
525
|
+
): number {
|
|
526
|
+
const lastIdx = messages.length - 1;
|
|
527
|
+
|
|
528
|
+
if (desiredStartIdx <= 0) {
|
|
529
|
+
return 0;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const windowIsCleanAt = markCleanWindowStarts(messages);
|
|
533
|
+
const cappedStartIdx = Math.min(desiredStartIdx, lastIdx);
|
|
534
|
+
|
|
535
|
+
for (let idx = cappedStartIdx; idx <= lastIdx; idx++) {
|
|
536
|
+
if (windowIsCleanAt[idx]) {
|
|
537
|
+
return idx;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
for (let idx = cappedStartIdx - 1; idx >= 0; idx--) {
|
|
542
|
+
if (windowIsCleanAt[idx]) {
|
|
543
|
+
return idx;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// No clean boundary exists anywhere: the supplied history itself carries a
|
|
548
|
+
// tool_result whose tool_use is not in it. Dropping messages cannot repair
|
|
549
|
+
// that, so the requested cut stands, floored at one message.
|
|
550
|
+
return cappedStartIdx;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* For every index, whether the window starting there contains no tool_result
|
|
555
|
+
* whose tool_use is missing from it. A result is matched by a tool_use in an
|
|
556
|
+
* earlier message or earlier in its own message, so one backward pass carrying
|
|
557
|
+
* the still-unmatched result ids answers every index in linear time.
|
|
558
|
+
*/
|
|
559
|
+
function markCleanWindowStarts(messages: MessageWithTokens[]): boolean[] {
|
|
560
|
+
const windowIsCleanAt = new Array<boolean>(messages.length).fill(false);
|
|
561
|
+
const resultIdsAwaitingTheirCall = new Set<string>();
|
|
562
|
+
|
|
563
|
+
for (let idx = messages.length - 1; idx >= 0; idx--) {
|
|
564
|
+
const callIdsInMessage = new Set<string>();
|
|
565
|
+
const resultIdsNeedingAnEarlierCall: string[] = [];
|
|
566
|
+
|
|
567
|
+
for (const block of messages[idx]!.message.content) {
|
|
568
|
+
if (block.type === 'tool_use') {
|
|
569
|
+
callIdsInMessage.add(block.id);
|
|
570
|
+
} else if (block.type === 'tool_result' && !callIdsInMessage.has(block.toolUseId)) {
|
|
571
|
+
resultIdsNeedingAnEarlierCall.push(block.toolUseId);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
for (const callId of callIdsInMessage) {
|
|
576
|
+
resultIdsAwaitingTheirCall.delete(callId);
|
|
577
|
+
}
|
|
578
|
+
for (const resultId of resultIdsNeedingAnEarlierCall) {
|
|
579
|
+
resultIdsAwaitingTheirCall.add(resultId);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
windowIsCleanAt[idx] = resultIdsAwaitingTheirCall.size === 0;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
return windowIsCleanAt;
|
|
374
586
|
}
|
|
375
587
|
|
|
376
588
|
export function placeCacheMarkers(
|
|
@@ -386,7 +598,7 @@ export function placeCacheMarkers(
|
|
|
386
598
|
return [];
|
|
387
599
|
}
|
|
388
600
|
|
|
389
|
-
const numPoints = cacheConfig.points ?? 1;
|
|
601
|
+
const numPoints = Math.min(cacheConfig.points ?? 1, MAX_MODULE_CACHE_POINTS);
|
|
390
602
|
const minTokens = cacheConfig.minTokens ?? 1024;
|
|
391
603
|
const preferUser = cacheConfig.preferUserMessages ?? true;
|
|
392
604
|
|
|
@@ -403,19 +615,30 @@ export function placeCacheMarkers(
|
|
|
403
615
|
const validMarkers = state.cacheMarkers.filter(m => currentIds.has(m.messageId));
|
|
404
616
|
|
|
405
617
|
if (validMarkers.length > 0) {
|
|
406
|
-
// Recalculate token estimates for valid markers
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
618
|
+
// Recalculate token estimates for valid markers, then apply the same cap
|
|
619
|
+
// fresh placement obeys. The cap is a property of the REQUEST (Anthropic
|
|
620
|
+
// accepts four cache_control blocks and the builders spend from the same
|
|
621
|
+
// budget), so honouring it only on the fresh path let a state written
|
|
622
|
+
// before the cap existed - or by a caller that asked for four points -
|
|
623
|
+
// keep re-spending four slots on every subsequent call, which is where
|
|
624
|
+
// the builders' own marker turns the request into a 400. The deepest
|
|
625
|
+
// markers cover the longest prefixes, so those are the ones kept.
|
|
626
|
+
const retainedMarkers = validMarkers
|
|
627
|
+
.map(marker => {
|
|
628
|
+
const idx = messages.findIndex(m => getMessageId(m) === marker.messageId);
|
|
629
|
+
const tokenEstimate = messageTokens
|
|
630
|
+
.slice(0, idx + 1)
|
|
631
|
+
.reduce((sum, m) => sum + m.tokens, 0);
|
|
632
|
+
|
|
633
|
+
return {
|
|
634
|
+
messageId: marker.messageId,
|
|
635
|
+
messageIndex: idx,
|
|
636
|
+
tokenEstimate,
|
|
637
|
+
};
|
|
638
|
+
})
|
|
639
|
+
.sort((a, b) => a.messageIndex - b.messageIndex);
|
|
640
|
+
|
|
641
|
+
return retainedMarkers.slice(-MAX_MODULE_CACHE_POINTS);
|
|
419
642
|
}
|
|
420
643
|
}
|
|
421
644
|
|
|
@@ -455,7 +678,12 @@ export function placeCacheMarkers(
|
|
|
455
678
|
|
|
456
679
|
// Adjust to user message if preferred
|
|
457
680
|
if (preferUser) {
|
|
458
|
-
const adjusted = findNearestUserMessage(
|
|
681
|
+
const adjusted = findNearestUserMessage(
|
|
682
|
+
messages,
|
|
683
|
+
markerIdx,
|
|
684
|
+
messageTokens,
|
|
685
|
+
config.assistantParticipant
|
|
686
|
+
);
|
|
459
687
|
if (adjusted) {
|
|
460
688
|
markerIdx = adjusted.index;
|
|
461
689
|
markerTokens = adjusted.tokens;
|
|
@@ -482,21 +710,35 @@ export function placeCacheMarkers(
|
|
|
482
710
|
return markers;
|
|
483
711
|
}
|
|
484
712
|
|
|
713
|
+
/** Assistant names assumed when the deployment configures none. */
|
|
714
|
+
const LEGACY_ASSISTANT_PARTICIPANTS = ['claude', 'assistant', 'bot', 'ai'];
|
|
715
|
+
|
|
485
716
|
function findNearestUserMessage(
|
|
486
717
|
messages: NormalizedMessage[],
|
|
487
718
|
startIdx: number,
|
|
488
|
-
messageTokens: MessageWithTokens[]
|
|
719
|
+
messageTokens: MessageWithTokens[],
|
|
720
|
+
assistantParticipant?: string
|
|
489
721
|
): { index: number; tokens: number } | null {
|
|
490
722
|
// Search backwards for a user message (non-assistant participant)
|
|
491
723
|
const maxSearch = 5;
|
|
492
724
|
|
|
725
|
+
// A deployment whose assistant is named anything else (Sol, a persona name)
|
|
726
|
+
// had every assistant turn classified as a user turn by the legacy list.
|
|
727
|
+
const assistantNames = assistantParticipant
|
|
728
|
+
? [assistantParticipant.toLowerCase()]
|
|
729
|
+
: LEGACY_ASSISTANT_PARTICIPANTS;
|
|
730
|
+
|
|
493
731
|
let tokens = messageTokens.slice(0, startIdx + 1).reduce((sum, m) => sum + m.tokens, 0);
|
|
494
732
|
|
|
495
733
|
for (let i = startIdx; i >= Math.max(0, startIdx - maxSearch); i--) {
|
|
496
734
|
const msg = messages[i]!;
|
|
497
|
-
// Heuristic: if participant isn't a
|
|
498
|
-
|
|
499
|
-
|
|
735
|
+
// Heuristic: if participant isn't a known assistant name, it's probably a
|
|
736
|
+
// user. A message with no participant at all (role-shaped producers) falls
|
|
737
|
+
// through the same way rather than crashing the whole call.
|
|
738
|
+
const participant = typeof msg.participant === 'string'
|
|
739
|
+
? msg.participant.toLowerCase()
|
|
740
|
+
: '';
|
|
741
|
+
const isUser = !assistantNames.includes(participant);
|
|
500
742
|
|
|
501
743
|
if (isUser) {
|
|
502
744
|
return { index: i, tokens };
|
|
@@ -522,6 +764,10 @@ export function applyCacheMarkers(
|
|
|
522
764
|
if (markerIndices.has(idx)) {
|
|
523
765
|
return {
|
|
524
766
|
...msg,
|
|
767
|
+
// cacheBreakpoint is the field every request builder reads; the
|
|
768
|
+
// metadata.cacheControl write has no reader inside membrane and is
|
|
769
|
+
// kept only for external consumers that may already read it.
|
|
770
|
+
cacheBreakpoint: true,
|
|
525
771
|
metadata: {
|
|
526
772
|
...msg.metadata,
|
|
527
773
|
cacheControl: { type: 'ephemeral' as const },
|