@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.
Files changed (172) hide show
  1. package/dist/cache-wire-receipt.d.ts +13 -0
  2. package/dist/cache-wire-receipt.d.ts.map +1 -0
  3. package/dist/cache-wire-receipt.js +108 -0
  4. package/dist/cache-wire-receipt.js.map +1 -0
  5. package/dist/context/index.d.ts +1 -1
  6. package/dist/context/index.d.ts.map +1 -1
  7. package/dist/context/index.js +1 -1
  8. package/dist/context/index.js.map +1 -1
  9. package/dist/context/process.d.ts +3 -1
  10. package/dist/context/process.d.ts.map +1 -1
  11. package/dist/context/process.js +230 -49
  12. package/dist/context/process.js.map +1 -1
  13. package/dist/context/types.d.ts +30 -0
  14. package/dist/context/types.d.ts.map +1 -1
  15. package/dist/context/types.js +16 -0
  16. package/dist/context/types.js.map +1 -1
  17. package/dist/floating-cache-marker.test.js +21 -0
  18. package/dist/floating-cache-marker.test.js.map +1 -1
  19. package/dist/formatters/anthropic-xml.d.ts +2 -0
  20. package/dist/formatters/anthropic-xml.d.ts.map +1 -1
  21. package/dist/formatters/anthropic-xml.js +64 -23
  22. package/dist/formatters/anthropic-xml.js.map +1 -1
  23. package/dist/formatters/native.d.ts.map +1 -1
  24. package/dist/formatters/native.js +16 -3
  25. package/dist/formatters/native.js.map +1 -1
  26. package/dist/formatters/normalize-tool-pairs.d.ts +7 -0
  27. package/dist/formatters/normalize-tool-pairs.d.ts.map +1 -1
  28. package/dist/formatters/normalize-tool-pairs.js +45 -0
  29. package/dist/formatters/normalize-tool-pairs.js.map +1 -1
  30. package/dist/formatters/types.d.ts +31 -0
  31. package/dist/formatters/types.d.ts.map +1 -1
  32. package/dist/index.d.ts +1 -0
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +1 -0
  35. package/dist/index.js.map +1 -1
  36. package/dist/membrane.d.ts +137 -8
  37. package/dist/membrane.d.ts.map +1 -1
  38. package/dist/membrane.js +823 -310
  39. package/dist/membrane.js.map +1 -1
  40. package/dist/providers/anthropic.d.ts +37 -0
  41. package/dist/providers/anthropic.d.ts.map +1 -1
  42. package/dist/providers/anthropic.js +142 -25
  43. package/dist/providers/anthropic.js.map +1 -1
  44. package/dist/providers/bedrock.d.ts +8 -0
  45. package/dist/providers/bedrock.d.ts.map +1 -1
  46. package/dist/providers/bedrock.js +24 -1
  47. package/dist/providers/bedrock.js.map +1 -1
  48. package/dist/providers/gemini.d.ts +11 -0
  49. package/dist/providers/gemini.d.ts.map +1 -1
  50. package/dist/providers/gemini.js +104 -82
  51. package/dist/providers/gemini.js.map +1 -1
  52. package/dist/providers/index.d.ts +1 -1
  53. package/dist/providers/index.d.ts.map +1 -1
  54. package/dist/providers/index.js.map +1 -1
  55. package/dist/providers/mock.d.ts +2 -0
  56. package/dist/providers/mock.d.ts.map +1 -1
  57. package/dist/providers/mock.js +2 -0
  58. package/dist/providers/mock.js.map +1 -1
  59. package/dist/providers/openai-compatible.d.ts +7 -0
  60. package/dist/providers/openai-compatible.d.ts.map +1 -1
  61. package/dist/providers/openai-compatible.js +99 -48
  62. package/dist/providers/openai-compatible.js.map +1 -1
  63. package/dist/providers/openai-completions.d.ts +5 -0
  64. package/dist/providers/openai-completions.d.ts.map +1 -1
  65. package/dist/providers/openai-completions.js +107 -44
  66. package/dist/providers/openai-completions.js.map +1 -1
  67. package/dist/providers/openai-responses-api.d.ts +16 -0
  68. package/dist/providers/openai-responses-api.d.ts.map +1 -1
  69. package/dist/providers/openai-responses-api.js +38 -10
  70. package/dist/providers/openai-responses-api.js.map +1 -1
  71. package/dist/providers/openai-responses.d.ts +2 -0
  72. package/dist/providers/openai-responses.d.ts.map +1 -1
  73. package/dist/providers/openai-responses.js +8 -1
  74. package/dist/providers/openai-responses.js.map +1 -1
  75. package/dist/providers/openai.d.ts +7 -0
  76. package/dist/providers/openai.d.ts.map +1 -1
  77. package/dist/providers/openai.js +92 -44
  78. package/dist/providers/openai.js.map +1 -1
  79. package/dist/providers/openrouter.d.ts +9 -0
  80. package/dist/providers/openrouter.d.ts.map +1 -1
  81. package/dist/providers/openrouter.js +118 -62
  82. package/dist/providers/openrouter.js.map +1 -1
  83. package/dist/providers/utils.d.ts +74 -2
  84. package/dist/providers/utils.d.ts.map +1 -1
  85. package/dist/providers/utils.js +184 -3
  86. package/dist/providers/utils.js.map +1 -1
  87. package/dist/registry/default-pricing.d.ts +22 -0
  88. package/dist/registry/default-pricing.d.ts.map +1 -1
  89. package/dist/registry/default-pricing.js +162 -28
  90. package/dist/registry/default-pricing.js.map +1 -1
  91. package/dist/types/content.d.ts +13 -0
  92. package/dist/types/content.d.ts.map +1 -1
  93. package/dist/types/content.js.map +1 -1
  94. package/dist/types/errors.d.ts +15 -0
  95. package/dist/types/errors.d.ts.map +1 -1
  96. package/dist/types/errors.js +26 -0
  97. package/dist/types/errors.js.map +1 -1
  98. package/dist/types/index.d.ts +3 -3
  99. package/dist/types/index.d.ts.map +1 -1
  100. package/dist/types/index.js +1 -1
  101. package/dist/types/index.js.map +1 -1
  102. package/dist/types/provider.d.ts +50 -0
  103. package/dist/types/provider.d.ts.map +1 -1
  104. package/dist/types/request.d.ts +6 -0
  105. package/dist/types/request.d.ts.map +1 -1
  106. package/dist/types/response.d.ts +85 -4
  107. package/dist/types/response.d.ts.map +1 -1
  108. package/dist/types/response.js.map +1 -1
  109. package/dist/types/streaming.d.ts +6 -1
  110. package/dist/types/streaming.d.ts.map +1 -1
  111. package/dist/utils/cache-marker-budget.d.ts +91 -0
  112. package/dist/utils/cache-marker-budget.d.ts.map +1 -0
  113. package/dist/utils/cache-marker-budget.js +208 -0
  114. package/dist/utils/cache-marker-budget.js.map +1 -0
  115. package/dist/utils/cost.d.ts +22 -0
  116. package/dist/utils/cost.d.ts.map +1 -1
  117. package/dist/utils/cost.js +55 -0
  118. package/dist/utils/cost.js.map +1 -1
  119. package/dist/utils/thinking-carriers.d.ts +80 -0
  120. package/dist/utils/thinking-carriers.d.ts.map +1 -0
  121. package/dist/utils/thinking-carriers.js +115 -0
  122. package/dist/utils/thinking-carriers.js.map +1 -0
  123. package/dist/utils/tool-parser.d.ts +24 -0
  124. package/dist/utils/tool-parser.d.ts.map +1 -1
  125. package/dist/utils/tool-parser.js +367 -161
  126. package/dist/utils/tool-parser.js.map +1 -1
  127. package/dist/utils/usage.d.ts +71 -0
  128. package/dist/utils/usage.d.ts.map +1 -0
  129. package/dist/utils/usage.js +161 -0
  130. package/dist/utils/usage.js.map +1 -0
  131. package/dist/yielding-stream.d.ts +8 -0
  132. package/dist/yielding-stream.d.ts.map +1 -1
  133. package/dist/yielding-stream.js +49 -3
  134. package/dist/yielding-stream.js.map +1 -1
  135. package/package.json +1 -1
  136. package/src/cache-wire-receipt.ts +125 -0
  137. package/src/context/index.ts +1 -0
  138. package/src/context/process.ts +305 -59
  139. package/src/context/types.ts +38 -0
  140. package/src/floating-cache-marker.test.ts +24 -0
  141. package/src/formatters/anthropic-xml.ts +66 -23
  142. package/src/formatters/native.ts +23 -2
  143. package/src/formatters/normalize-tool-pairs.ts +49 -0
  144. package/src/formatters/types.ts +35 -0
  145. package/src/index.ts +6 -0
  146. package/src/membrane.ts +974 -313
  147. package/src/providers/anthropic.ts +173 -19
  148. package/src/providers/bedrock.ts +25 -1
  149. package/src/providers/gemini.ts +125 -87
  150. package/src/providers/index.ts +1 -0
  151. package/src/providers/mock.ts +3 -0
  152. package/src/providers/openai-compatible.ts +102 -45
  153. package/src/providers/openai-completions.ts +112 -46
  154. package/src/providers/openai-responses-api.ts +45 -15
  155. package/src/providers/openai-responses.ts +8 -1
  156. package/src/providers/openai.ts +95 -42
  157. package/src/providers/openrouter.ts +125 -63
  158. package/src/providers/utils.ts +227 -6
  159. package/src/registry/default-pricing.ts +165 -28
  160. package/src/types/content.ts +14 -0
  161. package/src/types/errors.ts +28 -0
  162. package/src/types/index.ts +6 -0
  163. package/src/types/provider.ts +55 -0
  164. package/src/types/request.ts +7 -0
  165. package/src/types/response.ts +109 -8
  166. package/src/types/streaming.ts +8 -2
  167. package/src/utils/cache-marker-budget.ts +237 -0
  168. package/src/utils/cost.ts +64 -0
  169. package/src/utils/thinking-carriers.ts +125 -0
  170. package/src/utils/tool-parser.ts +473 -174
  171. package/src/utils/usage.ts +184 -0
  172. 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
+ }
@@ -25,6 +25,7 @@ export {
25
25
  createInitialState,
26
26
  defaultTokenEstimator,
27
27
  DEFAULT_CONTEXT_CONFIG,
28
+ MembraneContextIdentityError,
28
29
  } from './types.js';
29
30
 
30
31
  export type {
@@ -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
- didRoll = true;
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
- const current = unit === 'messages' ? state.messagesSinceRoll : state.tokensSinceRoll;
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 (targetMessages && messages.length > targetMessages) {
331
- const startIdx = messages.length - targetMessages;
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
- if (targetTokens) {
339
- let tokenSum = 0;
340
- let startIdx = messages.length;
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
- // Count from end backwards
343
- for (let i = messages.length - 1; i >= 0; i--) {
344
- tokenSum += messages[i]!.tokens;
345
- if (tokenSum > targetTokens) {
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
- // Default: use buffer from config
359
- const buffer = config?.rolling.buffer ?? 20;
360
- const unit = config?.rolling.unit ?? 'messages';
472
+ if (candidateStartIndices.length === 0) {
473
+ return { kept: messages, dropped: 0 };
474
+ }
361
475
 
362
- if (unit === 'messages') {
363
- const targetCount = Math.max(buffer * 2, messages.length - buffer);
364
- if (messages.length > targetCount) {
365
- const startIdx = messages.length - targetCount;
366
- return {
367
- kept: messages.slice(startIdx),
368
- dropped: startIdx,
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 { kept: messages, dropped: 0 };
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
- return validMarkers.map(marker => {
408
- const idx = messages.findIndex(m => getMessageId(m) === marker.messageId);
409
- const tokenEstimate = messageTokens
410
- .slice(0, idx + 1)
411
- .reduce((sum, m) => sum + m.tokens, 0);
412
-
413
- return {
414
- messageId: marker.messageId,
415
- messageIndex: idx,
416
- tokenEstimate,
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(messages, markerIdx, messageTokens);
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 common assistant name, it's probably a user
498
- const participant = msg.participant.toLowerCase();
499
- const isUser = !['claude', 'assistant', 'bot', 'ai'].includes(participant);
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 },