@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
@@ -41,110 +41,241 @@ function parseParamValue(value: string): unknown {
41
41
  }
42
42
  }
43
43
 
44
+ /**
45
+ * Parse the parameters of one invoke body. A self-closing invoke has no body
46
+ * and therefore no parameters.
47
+ */
48
+ function parseInvokeParameters(invokeBody: string | undefined): Record<string, unknown> {
49
+ const input: Record<string, unknown> = {};
50
+ if (invokeBody === undefined) return input;
51
+
52
+ PARAMETER_REGEX.lastIndex = 0;
53
+ let paramMatch: RegExpExecArray | null;
54
+ while ((paramMatch = PARAMETER_REGEX.exec(invokeBody)) !== null) {
55
+ input[paramMatch[2] ?? ''] = parseParamValue(paramMatch[3] ?? '');
56
+ }
57
+ return input;
58
+ }
59
+
44
60
  // ============================================================================
45
61
  // Tool Call Parsing
46
62
  // ============================================================================
47
63
 
48
- // Regex patterns supporting both plain and antml: prefix
49
- // Pattern matches: <function_calls> or <function_calls>
50
- const FUNCTION_CALLS_REGEX = /<(antml:)?function_calls>([\s\S]*?)<\/(antml:)?function_calls>/g;
64
+ // Invoke tags, both forms in ONE alternation so a block that mixes them keeps
65
+ // document order (two sequential passes appended full-then-self-closing).
66
+ // The name may be single- or double-quoted and whitespace may precede the
67
+ // closing angle bracket; the quote character is backreferenced so the opposite
68
+ // quote stays legal inside the name.
69
+ // The name is at least one character and cannot contain its own quote, so a
70
+ // nameless invoke and a stray second attribute both fail to match rather than
71
+ // yielding a garbage tool name — a block that parses to no invokes is reported.
72
+ // Groups: 1 = antml prefix, 2 = quote char, 3 = name, 4 = body (full form only).
73
+ const INVOKE_REGEX =
74
+ /<(antml:)?invoke\s+name=(["'])((?:(?!\2).)+)\2\s*(?:\/>|>([\s\S]*?)<\/(antml:)?invoke>)/g;
75
+
76
+ const INVOKE_NAME_GROUP = 3;
77
+ const INVOKE_BODY_GROUP = 4;
78
+ const INVOKE_CLOSER_PREFIX_GROUP = 5;
79
+
80
+ // Same pattern without /g, for the re-anchor read that runs INSIDE the outer
81
+ // iteration: sharing the /g instance would clobber its lastIndex. Built from
82
+ // the one source so the two can never drift apart.
83
+ const INVOKE_REANCHOR_REGEX = new RegExp(INVOKE_REGEX.source);
84
+
85
+ // An invoke OPENER on its own — the containment test one level below the
86
+ // block fix, since an opener that never closed is invisible to INVOKE_REGEX.
87
+ const INVOKE_OPEN_REGEX = /<(antml:)?invoke\s+name=/g;
88
+
89
+ const INVOKE_CLOSE_TAG = '</invoke>';
90
+
91
+ /**
92
+ * The invokes one block's inner content dispatches, plus the heads it refused.
93
+ *
94
+ * The full-form alternative of INVOKE_REGEX is lazy, so an invoke the model
95
+ * left OPEN pairs with the NEXT invoke's closing tag: the head matched, carried
96
+ * the inner call's parameters as its own, and the inner call never parsed at
97
+ * all — the same block-splice disease one level down. A match whose BODY holds
98
+ * another opener is therefore refused as a dispatchable invoke and re-anchored
99
+ * to the innermost opener inside it, which is where the live call begins. The
100
+ * re-anchored text holds no further opener by construction, so this terminates.
101
+ */
102
+ interface ParsedInvokes {
103
+ calls: Array<{ name: string; input: Record<string, unknown> }>;
104
+ unclosedHeads: number;
105
+ }
106
+
107
+ /** Offsets of every invoke opener inside one matched invoke body. */
108
+ function invokeOpenerOffsets(invokeBody: string): number[] {
109
+ const offsets: number[] = [];
110
+ INVOKE_OPEN_REGEX.lastIndex = 0;
111
+ let openerMatch: RegExpExecArray | null;
112
+ while ((openerMatch = INVOKE_OPEN_REGEX.exec(invokeBody)) !== null) {
113
+ offsets.push(openerMatch.index);
114
+ }
115
+ return offsets;
116
+ }
117
+
118
+ function collectInvokes(innerContent: string): ParsedInvokes {
119
+ const calls: Array<{ name: string; input: Record<string, unknown> }> = [];
120
+ let unclosedHeads = 0;
121
+
122
+ INVOKE_REGEX.lastIndex = 0;
123
+ let invokeMatch: RegExpExecArray | null;
124
+ while ((invokeMatch = INVOKE_REGEX.exec(innerContent)) !== null) {
125
+ const invokeBody = invokeMatch[INVOKE_BODY_GROUP];
126
+ const swallowedOpenerOffsets = invokeBody === undefined ? [] : invokeOpenerOffsets(invokeBody);
127
+
128
+ if (swallowedOpenerOffsets.length === 0) {
129
+ calls.push({
130
+ name: invokeMatch[INVOKE_NAME_GROUP] ?? '',
131
+ input: parseInvokeParameters(invokeBody),
132
+ });
133
+ continue;
134
+ }
51
135
 
52
- // Full invoke tags with content
53
- const INVOKE_REGEX_FULL = /<(antml:)?invoke\s+name="([^"]+)">([\s\S]*?)<\/(antml:)?invoke>/g;
136
+ // Every opener in the body is a head that never closed, and so is the
137
+ // matched head itself; only the innermost one owns the closing tag.
138
+ unclosedHeads += swallowedOpenerOffsets.length;
139
+
140
+ const fullMatch = invokeMatch[0];
141
+ const closerLength =
142
+ INVOKE_CLOSE_TAG.length + (invokeMatch[INVOKE_CLOSER_PREFIX_GROUP]?.length ?? 0);
143
+ const bodyOffset = fullMatch.length - closerLength - invokeBody!.length;
144
+ const innermostOffset = swallowedOpenerOffsets[swallowedOpenerOffsets.length - 1]!;
145
+ const reanchoredMatch = INVOKE_REANCHOR_REGEX.exec(fullMatch.slice(bodyOffset + innermostOffset));
146
+
147
+ // A re-anchored head that still does not parse — a nameless invoke, say —
148
+ // is refused like any other: counted above, dispatched never.
149
+ if (reanchoredMatch) {
150
+ calls.push({
151
+ name: reanchoredMatch[INVOKE_NAME_GROUP] ?? '',
152
+ input: parseInvokeParameters(reanchoredMatch[INVOKE_BODY_GROUP]),
153
+ });
154
+ }
155
+ }
54
156
 
55
- // Self-closing invoke tags (no parameters)
56
- const INVOKE_REGEX_SELF_CLOSE = /<(antml:)?invoke\s+name="([^"]+)"\s*\/>/g;
157
+ return { calls, unclosedHeads };
158
+ }
57
159
 
58
160
  // Parameter tags
59
161
  const PARAMETER_REGEX = /<(antml:)?parameter\s+name="([^"]+)">([\s\S]*?)<\/(antml:)?parameter>/g;
60
162
 
61
- // Check for function_results following a block
62
- const FUNCTION_RESULTS_START = /<(antml:)?function_results>/;
163
+ const FUNCTION_CALLS_OPEN_REGEX = /<(antml:)?function_calls>/g;
63
164
 
64
165
  /**
65
- * Parse tool calls from text containing XML function_calls blocks
166
+ * One <function_calls> block, resolved to the span its calls may be read from.
66
167
  *
67
- * Uses "last-unexecuted-block" logic: finds the last function_calls block
68
- * that doesn't have function_results immediately following it.
168
+ * The block regexes are flat: first opener to first closer, with no nesting
169
+ * check. When a max_tokens truncation leaves an unclosed block in the persisted
170
+ * turn, the next round's closer splices onto that stale opener and the match
171
+ * spans two rounds — so the stale invoke pairs with the NEW round's `</invoke>`
172
+ * and one call is dispatched bearing the stale tool's name and everything
173
+ * between as its argument, including intervening user text.
174
+ *
175
+ * A match whose inner content holds another opener is therefore REJECTED as a
176
+ * block: no dispatch ever crosses an opener. The span is then re-anchored to
177
+ * the innermost (last) opener inside it, which is where the live block actually
178
+ * begins, so the real call still runs and the stale half falls out as ordinary
179
+ * preceding text. Re-anchoring terminates: the re-anchored inner content holds
180
+ * no opener by construction.
69
181
  */
70
- export function parseToolCalls(text: string): ParsedToolCalls | null {
71
- // Reset regex
72
- FUNCTION_CALLS_REGEX.lastIndex = 0;
73
-
74
- // Find all function_calls blocks and pick the last unexecuted one
75
- let blockMatch: RegExpExecArray | null = null;
76
- let lastUnexecutedMatch: RegExpExecArray | null = null;
77
-
78
- while ((blockMatch = FUNCTION_CALLS_REGEX.exec(text)) !== null) {
79
- // Check if this block already has results after it
80
- const afterPos = blockMatch.index + blockMatch[0].length;
81
- const textAfter = text.slice(afterPos, afterPos + 100); // Check next 100 chars
82
-
83
- if (!FUNCTION_RESULTS_START.test(textAfter.trimStart())) {
84
- // This block hasn't been executed yet - store it
85
- // Need to capture all properties since exec returns are reused
86
- lastUnexecutedMatch = {
87
- ...blockMatch,
88
- index: blockMatch.index,
89
- input: blockMatch.input,
90
- } as RegExpExecArray;
91
- }
92
- }
182
+ interface ResolvedToolBlock {
183
+ start: number;
184
+ end: number;
185
+ innerContent: string;
186
+ fullMatch: string;
187
+ wasSpliced: boolean;
188
+ }
93
189
 
94
- if (!lastUnexecutedMatch) {
95
- return null;
96
- }
190
+ function resolveToolBlock(text: string, blockMatch: RegExpExecArray): ResolvedToolBlock {
191
+ const end = blockMatch.index + blockMatch[0].length;
192
+ const innerContent = blockMatch[2] ?? '';
193
+ const innerStart = blockMatch.index + blockMatch[0].indexOf('>') + 1;
97
194
 
98
- const fullMatch = lastUnexecutedMatch[0];
99
- const innerContent = lastUnexecutedMatch[2] ?? ''; // Group 2 is content between tags
100
- const matchIndex = lastUnexecutedMatch.index;
195
+ FUNCTION_CALLS_OPEN_REGEX.lastIndex = 0;
196
+ let innermostOpener: RegExpExecArray | null = null;
197
+ let nestedMatch: RegExpExecArray | null;
198
+ while ((nestedMatch = FUNCTION_CALLS_OPEN_REGEX.exec(innerContent)) !== null) {
199
+ innermostOpener = nestedMatch;
200
+ }
101
201
 
102
- const beforeText = text.slice(0, matchIndex);
103
- const afterText = text.slice(matchIndex + fullMatch.length);
202
+ if (!innermostOpener) {
203
+ return {
204
+ start: blockMatch.index,
205
+ end,
206
+ innerContent,
207
+ fullMatch: blockMatch[0],
208
+ wasSpliced: false,
209
+ };
210
+ }
104
211
 
105
- const calls: ToolCall[] = [];
212
+ const reanchoredStart = innerStart + innermostOpener.index;
213
+ return {
214
+ start: reanchoredStart,
215
+ end,
216
+ innerContent: innerContent.slice(innermostOpener.index + innermostOpener[0].length),
217
+ fullMatch: text.slice(reanchoredStart, end),
218
+ wasSpliced: true,
219
+ };
220
+ }
106
221
 
107
- // Parse full invoke tags (with content and closing tag)
108
- INVOKE_REGEX_FULL.lastIndex = 0;
109
- let invokeMatch: RegExpExecArray | null;
222
+ // Openers that mark a block as already executed, when one is the very next
223
+ // token after it
224
+ const FUNCTION_RESULTS_START_ANCHORED = /^<(antml:)?function_results>/;
110
225
 
111
- while ((invokeMatch = INVOKE_REGEX_FULL.exec(innerContent)) !== null) {
112
- const toolName = invokeMatch[2] ?? ''; // Group 2 is the name
113
- const invokeContent = invokeMatch[3] ?? ''; // Group 3 is the content
226
+ // Longest opener the anchored test can match, so the slice it reads is bounded
227
+ const FUNCTION_RESULTS_OPENER_MAX_LENGTH = '<function_results>'.length + 'antml:'.length;
114
228
 
115
- // Parse parameters
116
- const input: Record<string, unknown> = {};
117
- PARAMETER_REGEX.lastIndex = 0;
118
- let paramMatch: RegExpExecArray | null;
229
+ const SINGLE_WHITESPACE_REGEX = /\s/;
119
230
 
120
- while ((paramMatch = PARAMETER_REGEX.exec(invokeContent)) !== null) {
121
- const paramName = paramMatch[2] ?? ''; // Group 2 is the name
122
- const paramValue = paramMatch[3] ?? ''; // Group 3 is the value
231
+ /**
232
+ * Has this block already been executed? True only when the next NON-WHITESPACE
233
+ * token after it is a function_results opener. The previous test — "a
234
+ * function_results opener appears anywhere in the next 100 characters" — was
235
+ * wrong in both directions: padding past 100 characters re-selected a block
236
+ * that had already run, and a results block belonging to some later exchange
237
+ * marked a live call as spent.
238
+ */
239
+ function isFollowedByResults(text: string, afterPos: number): boolean {
240
+ let scan = afterPos;
241
+ while (scan < text.length && SINGLE_WHITESPACE_REGEX.test(text[scan]!)) scan++;
242
+ return FUNCTION_RESULTS_START_ANCHORED.test(
243
+ text.slice(scan, scan + FUNCTION_RESULTS_OPENER_MAX_LENGTH)
244
+ );
245
+ }
123
246
 
124
- // Parse value with special handling for large integers
125
- input[paramName] = parseParamValue(paramValue);
247
+ /**
248
+ * Parse tool calls from text containing XML function_calls blocks
249
+ *
250
+ * Uses "last-unexecuted-block" logic: finds the last function_calls block
251
+ * that doesn't have function_results immediately following it.
252
+ */
253
+ export function parseToolCalls(text: string): ParsedToolCalls | null {
254
+ // Pick the last unexecuted block among those that survive containment: a
255
+ // block quoted inside thinking or echoed in a tool result is content, and
256
+ // dispatching from it runs a call the model never made.
257
+ let lastUnexecutedBlock: ResolvedToolBlock | null = null;
258
+
259
+ for (const block of collectLiveToolBlocks(text)) {
260
+ if (!isFollowedByResults(text, block.end)) {
261
+ lastUnexecutedBlock = block;
126
262
  }
263
+ }
127
264
 
128
- calls.push({
129
- id: generateToolId(),
130
- name: toolName,
131
- input,
132
- });
265
+ if (!lastUnexecutedBlock) {
266
+ return null;
133
267
  }
134
268
 
135
- // Parse self-closing invoke tags (no parameters)
136
- INVOKE_REGEX_SELF_CLOSE.lastIndex = 0;
137
- let selfCloseMatch: RegExpExecArray | null;
269
+ const { innerContent, fullMatch } = lastUnexecutedBlock;
138
270
 
139
- while ((selfCloseMatch = INVOKE_REGEX_SELF_CLOSE.exec(innerContent)) !== null) {
140
- const toolName = selfCloseMatch[2] ?? ''; // Group 2 is the name
271
+ const beforeText = text.slice(0, lastUnexecutedBlock.start);
272
+ const afterText = text.slice(lastUnexecutedBlock.end);
141
273
 
142
- calls.push({
143
- id: generateToolId(),
144
- name: toolName,
145
- input: {}, // No parameters for self-closing tag
146
- });
147
- }
274
+ const calls: ToolCall[] = collectInvokes(innerContent).calls.map((invoke) => ({
275
+ id: generateToolId(),
276
+ name: invoke.name,
277
+ input: invoke.input,
278
+ }));
148
279
 
149
280
  return {
150
281
  calls,
@@ -336,6 +467,142 @@ export function formatToolDefinitions(tools: ToolDefinitionForPrompt[]): string
336
467
  // Regex for matching thinking blocks (both plain and antml: prefixed)
337
468
  const THINKING_BLOCK_REGEX = /<(antml:)?thinking>([\s\S]*?)<\/(antml:)?thinking>/g;
338
469
 
470
+ /**
471
+ * One span the block sweeps found, before anything about it is registered.
472
+ *
473
+ * The sweeps are independent and their spans overlap: a function_calls block
474
+ * quoted inside a thinking block is found by both. Containment decides which
475
+ * spans are real, and it has to decide FIRST — every side effect downstream
476
+ * (call sites, legacy-result claims, diagnostic counts) is scoped to the
477
+ * survivors.
478
+ */
479
+ type CandidateSpan =
480
+ | { kind: 'thinking'; start: number; end: number; thinking: string }
481
+ | { kind: 'calls'; start: number; end: number; resolvedBlock: ResolvedToolBlock }
482
+ | { kind: 'results'; start: number; end: number; innerContent: string; rawXml: string };
483
+
484
+ /**
485
+ * Keep the outermost spans, in document order. RETAINED SPANS NEVER OVERLAP:
486
+ * a span whose START lies inside one already kept is refused, whether it ends
487
+ * inside that container or crosses out past it.
488
+ *
489
+ * Sorted by start with the outermost first on a tie, a span that begins before
490
+ * the furthest retained end begins INSIDE a container, and everything a
491
+ * container encloses is its content — the model wrote it, it did not call it.
492
+ * Testing only `end` let a crossing span through: a call opening inside a
493
+ * thinking block or a tool result and closing after it was retained alongside
494
+ * its container, so the quoted call dispatched and the overlapping source text
495
+ * was emitted twice — once as thinking content, once as a real tool_use — while
496
+ * the text cursor walked past the inner span, printing the container's own
497
+ * closing tag as visible model text. A dangling closer left downstream by such
498
+ * a refusal is what it looks like: ordinary text.
499
+ *
500
+ * The bound is unchanged: only a CLOSED container is visible here, so the
501
+ * streaming stop-sequence path, where the text is cut at `</function_calls>`
502
+ * before the enclosing `<thinking>` closes, is still outside this rule.
503
+ */
504
+ function retainOutermostSpans(spans: CandidateSpan[]): CandidateSpan[] {
505
+ const sorted = [...spans].sort((a, b) => a.start - b.start || b.end - a.end);
506
+ const retained: CandidateSpan[] = [];
507
+ let furthestRetainedEnd = -1;
508
+ for (const span of sorted) {
509
+ if (span.start < furthestRetainedEnd) continue;
510
+ retained.push(span);
511
+ furthestRetainedEnd = span.end;
512
+ }
513
+ return retained;
514
+ }
515
+
516
+ /**
517
+ * The text left once every retained span is cut out — the document's own
518
+ * structural level.
519
+ *
520
+ * Structural diagnostics read this rather than the raw text, because a tool
521
+ * tag QUOTED inside a thinking block or a tool result is content, not
522
+ * structure: counting it made a model that merely described an unclosed block
523
+ * indistinguishable from one whose block was truncated mid-write.
524
+ */
525
+ /**
526
+ * Every span the three block sweeps find, unfiltered and unregistered.
527
+ *
528
+ * Both parse entry points read the document through this one census, so the
529
+ * dispatch path and the block path can never disagree about which blocks are
530
+ * real.
531
+ */
532
+ function collectCandidateSpans(text: string): CandidateSpan[] {
533
+ const spans: CandidateSpan[] = [];
534
+
535
+ THINKING_BLOCK_REGEX.lastIndex = 0;
536
+ let thinkingMatch: RegExpExecArray | null;
537
+ while ((thinkingMatch = THINKING_BLOCK_REGEX.exec(text)) !== null) {
538
+ spans.push({
539
+ kind: 'thinking',
540
+ start: thinkingMatch.index,
541
+ end: thinkingMatch.index + thinkingMatch[0].length,
542
+ thinking: thinkingMatch[2] ?? '',
543
+ });
544
+ }
545
+
546
+ FUNCTION_BLOCK_WITH_CONTENT_REGEX.lastIndex = 0;
547
+ let funcMatch: RegExpExecArray | null;
548
+ while ((funcMatch = FUNCTION_BLOCK_WITH_CONTENT_REGEX.exec(text)) !== null) {
549
+ // A spliced match (a stale opener joined to a later round's closer) is
550
+ // rejected as a block and re-anchored to its innermost opener; see
551
+ // resolveToolBlock. Containment reads the RESOLVED span, which is where
552
+ // the live block actually begins.
553
+ const resolvedBlock = resolveToolBlock(text, funcMatch);
554
+ spans.push({
555
+ kind: 'calls',
556
+ start: resolvedBlock.start,
557
+ end: resolvedBlock.end,
558
+ resolvedBlock,
559
+ });
560
+ }
561
+
562
+ FUNCTION_RESULTS_BLOCK_REGEX.lastIndex = 0;
563
+ let resultsMatch: RegExpExecArray | null;
564
+ while ((resultsMatch = FUNCTION_RESULTS_BLOCK_REGEX.exec(text)) !== null) {
565
+ spans.push({
566
+ kind: 'results',
567
+ start: resultsMatch.index,
568
+ end: resultsMatch.index + resultsMatch[0].length,
569
+ innerContent: resultsMatch[2] ?? '',
570
+ // Verbatim document text the harness placed — carried for exact replay
571
+ // on the prefill path (membrane#36).
572
+ rawXml: resultsMatch[0],
573
+ });
574
+ }
575
+
576
+ return spans;
577
+ }
578
+
579
+ /**
580
+ * The function_calls blocks that are structure rather than content: those that
581
+ * survive containment, in document order.
582
+ *
583
+ * A block quoted inside a <thinking> block or echoed inside a tool result is
584
+ * text the model wrote ABOUT a call, not a call. Selecting from the raw sweep
585
+ * dispatched it — a model that named a tool and explicitly declined to use it
586
+ * had it executed anyway.
587
+ */
588
+ function collectLiveToolBlocks(text: string): ResolvedToolBlock[] {
589
+ const live: ResolvedToolBlock[] = [];
590
+ for (const span of retainOutermostSpans(collectCandidateSpans(text))) {
591
+ if (span.kind === 'calls') live.push(span.resolvedBlock);
592
+ }
593
+ return live;
594
+ }
595
+
596
+ function textOutsideSpans(text: string, spans: CandidateSpan[]): string {
597
+ let residue = '';
598
+ let cursor = 0;
599
+ for (const span of spans) {
600
+ if (span.start > cursor) residue += text.slice(cursor, span.start);
601
+ cursor = Math.max(cursor, span.end);
602
+ }
603
+ return residue + text.slice(cursor);
604
+ }
605
+
339
606
  // Regex for matching function_calls blocks with their content
340
607
  const FUNCTION_BLOCK_WITH_CONTENT_REGEX = /<(antml:)?function_calls>([\s\S]*?)<\/(antml:)?function_calls>/g;
341
608
 
@@ -369,6 +636,30 @@ export function parseAccumulatedIntoBlocks(
369
636
  blocks: ContentBlock[];
370
637
  toolCalls: ToolCall[];
371
638
  toolResults: ToolResult[];
639
+ /**
640
+ * The text ends inside a tool block — an opener with no closer, or a cut
641
+ * mid-tag. The membrane loop does not resume on a length stop, so this is
642
+ * what a max_tokens truncation leaves behind, and persisting it bare lets the
643
+ * next round's closer splice onto the stale opener.
644
+ */
645
+ unclosedToolBlock: boolean;
646
+ /**
647
+ * function_calls blocks that yielded no invokes at all. Always a defect —
648
+ * never how a well-formed block ends.
649
+ */
650
+ emptyToolBlocks: number;
651
+ /**
652
+ * Block matches that spanned another opener and were re-anchored to the
653
+ * innermost one. Each is a truncated block that was persisted bare and is
654
+ * being repaired at read time.
655
+ */
656
+ splicedToolBlocks: number;
657
+ /**
658
+ * `<invoke>` heads that were left open and swallowed a later invoke, so the
659
+ * match was refused and re-anchored to the call it absorbed. Nothing was
660
+ * dispatched under the head's name, and the head itself never ran.
661
+ */
662
+ unclosedInvokeHeads: number;
372
663
  } {
373
664
  // If we're starting inside a block from prefill, prepend a synthetic opening tag
374
665
  // so the regex can match the closing tag properly
@@ -383,12 +674,17 @@ export function parseAccumulatedIntoBlocks(
383
674
  const blocks: ContentBlock[] = [];
384
675
  const toolCalls: ToolCall[] = [];
385
676
  const toolResults: ToolResult[] = [];
677
+ let emptyToolBlocks = 0;
678
+ let splicedToolBlocks = 0;
679
+ let unclosedInvokeHeads = 0;
386
680
 
387
681
  // Track positions of all special blocks to extract plain text between them
388
682
  type BlockPosition = {
389
683
  start: number;
390
684
  end: number;
391
685
  block: ContentBlock | ContentBlock[];
686
+ calls?: ToolCall[];
687
+ results?: ToolResult[];
392
688
  };
393
689
  const positions: BlockPosition[] = [];
394
690
 
@@ -407,98 +703,91 @@ export function parseAccumulatedIntoBlocks(
407
703
  return generateToolId();
408
704
  };
409
705
 
410
- // Find all thinking blocks
411
- THINKING_BLOCK_REGEX.lastIndex = 0;
412
- let thinkingMatch: RegExpExecArray | null;
413
- while ((thinkingMatch = THINKING_BLOCK_REGEX.exec(processedText)) !== null) {
414
- positions.push({
415
- start: thinkingMatch.index,
416
- end: thinkingMatch.index + thinkingMatch[0].length,
417
- block: {
418
- type: 'thinking',
419
- thinking: thinkingMatch[2] ?? '',
420
- },
421
- });
422
- }
423
-
424
- // Find all function_calls blocks and parse their tool calls
425
- FUNCTION_BLOCK_WITH_CONTENT_REGEX.lastIndex = 0;
426
- let funcMatch: RegExpExecArray | null;
427
- while ((funcMatch = FUNCTION_BLOCK_WITH_CONTENT_REGEX.exec(processedText)) !== null) {
428
- const innerContent = funcMatch[2] ?? '';
429
- // Verbatim document text of the whole block carried on each parsed
430
- // tool_use so prefill replay reproduces the generation exactly instead
431
- // of synthesizing a paraphrase (membrane#36).
432
- const rawXml = funcMatch[0];
433
- const blockToolCalls: ContentBlock[] = [];
434
-
435
- // Parse invoke tags in this block
436
- INVOKE_REGEX_FULL.lastIndex = 0;
437
- let invokeMatch: RegExpExecArray | null;
438
- while ((invokeMatch = INVOKE_REGEX_FULL.exec(innerContent)) !== null) {
439
- const toolName = invokeMatch[2] ?? '';
440
- const invokeContent = invokeMatch[3] ?? '';
441
- const input: Record<string, unknown> = {};
442
-
443
- // Parse parameters
444
- PARAMETER_REGEX.lastIndex = 0;
445
- let paramMatch: RegExpExecArray | null;
446
- while ((paramMatch = PARAMETER_REGEX.exec(invokeContent)) !== null) {
447
- const paramName = paramMatch[2] ?? '';
448
- const paramValue = paramMatch[3] ?? '';
449
- // Parse value with special handling for large integers
450
- input[paramName] = parseParamValue(paramValue);
451
- }
452
-
453
- const id = generateToolId();
454
- const toolCall: ToolCall = { id, name: toolName, input };
455
- toolCalls.push(toolCall);
456
- callSites.push({ id, name: toolName, pos: funcMatch.index });
457
- blockToolCalls.push({
458
- type: 'tool_use',
459
- id,
460
- name: toolName,
461
- input,
462
- rawXml,
706
+ // ── Pass 1: collect every candidate span, with NO side effects ───────────
707
+ //
708
+ // The three sweeps overlap by construction (a function_calls block quoted
709
+ // inside a thinking block is found by both), so nothing may be registered —
710
+ // no callSites entry, no legacy-result claim, no diagnostic count — until
711
+ // containment has decided which spans are real. Registering first and
712
+ // filtering after produced a call site that a legacy result could claim and
713
+ // that the filter then deleted, leaving a tool_result addressed to a
714
+ // tool_use no longer in the response.
715
+ const candidateSpans = collectCandidateSpans(processedText);
716
+
717
+ // ── Pass 2: containment ──────────────────────────────────────────────────
718
+ //
719
+ // Sorted by start, outermost span first on a tie: a span that BEGINS before
720
+ // the furthest retained end begins inside a container, so it is content of
721
+ // that container, not a sibling of it — retained spans never overlap, and a
722
+ // span that crosses out past its container is refused with the rest of the
723
+ // container's content. Zero-invoke and zero-result spans take part as
724
+ // containers even though they contribute no block of their own — whether a
725
+ // span holds anything parseable says nothing about whether it encloses the
726
+ // text beneath it.
727
+ const survivingSpans = retainOutermostSpans(candidateSpans);
728
+
729
+ // ── Pass 3: register the survivors, in document order ────────────────────
730
+ //
731
+ // One ordered walk, so every call site preceding a results block is already
732
+ // registered when that block claims — and no filtered span ever was.
733
+ for (const span of survivingSpans) {
734
+ if (span.kind === 'thinking') {
735
+ positions.push({
736
+ start: span.start,
737
+ end: span.end,
738
+ block: { type: 'thinking', thinking: span.thinking },
463
739
  });
740
+ continue;
464
741
  }
465
742
 
466
- // Parse self-closing invoke tags
467
- INVOKE_REGEX_SELF_CLOSE.lastIndex = 0;
468
- let selfCloseMatch: RegExpExecArray | null;
469
- while ((selfCloseMatch = INVOKE_REGEX_SELF_CLOSE.exec(innerContent)) !== null) {
470
- const toolName = selfCloseMatch[2] ?? '';
471
- const id = generateToolId();
472
- const toolCall: ToolCall = { id, name: toolName, input: {} };
473
- toolCalls.push(toolCall);
474
- callSites.push({ id, name: toolName, pos: funcMatch.index });
475
- blockToolCalls.push({
476
- type: 'tool_use',
477
- id,
478
- name: toolName,
479
- input: {},
480
- rawXml,
481
- });
482
- }
743
+ if (span.kind === 'calls') {
744
+ const resolvedBlock = span.resolvedBlock;
745
+ if (resolvedBlock.wasSpliced) splicedToolBlocks++;
746
+ // Verbatim document text of the whole block — carried on each parsed
747
+ // tool_use so prefill replay reproduces the generation exactly instead
748
+ // of synthesizing a paraphrase (membrane#36).
749
+ const rawXml = resolvedBlock.fullMatch;
750
+ const blockToolCalls: ContentBlock[] = [];
751
+ const blockCalls: ToolCall[] = [];
752
+
753
+ // Parse invoke tags in this block (both forms, in document order); an
754
+ // invoke left open is refused and re-anchored to the call it swallowed.
755
+ const parsedInvokes = collectInvokes(resolvedBlock.innerContent);
756
+ unclosedInvokeHeads += parsedInvokes.unclosedHeads;
757
+ for (const invoke of parsedInvokes.calls) {
758
+ const toolName = invoke.name;
759
+ const input = invoke.input;
760
+
761
+ const id = generateToolId();
762
+ blockCalls.push({ id, name: toolName, input });
763
+ callSites.push({ id, name: toolName, pos: resolvedBlock.start });
764
+ blockToolCalls.push({
765
+ type: 'tool_use',
766
+ id,
767
+ name: toolName,
768
+ input,
769
+ rawXml,
770
+ });
771
+ }
483
772
 
484
- if (blockToolCalls.length > 0) {
485
- positions.push({
486
- start: funcMatch.index,
487
- end: funcMatch.index + funcMatch[0].length,
488
- block: blockToolCalls,
489
- });
773
+ if (blockToolCalls.length === 0) {
774
+ emptyToolBlocks++;
775
+ } else {
776
+ positions.push({
777
+ start: resolvedBlock.start,
778
+ end: resolvedBlock.end,
779
+ block: blockToolCalls,
780
+ calls: blockCalls,
781
+ });
782
+ }
783
+ continue;
490
784
  }
491
- }
492
785
 
493
- // Find all function_results blocks and parse their results
494
- FUNCTION_RESULTS_BLOCK_REGEX.lastIndex = 0;
495
- let resultsMatch: RegExpExecArray | null;
496
- while ((resultsMatch = FUNCTION_RESULTS_BLOCK_REGEX.exec(processedText)) !== null) {
497
- const innerContent = resultsMatch[2] ?? '';
498
- // Verbatim document text the harness placed — carried for exact replay
499
- // on the prefill path (membrane#36).
500
- const rawXml = resultsMatch[0];
786
+ const innerContent = span.innerContent;
787
+ const rawXml = span.rawXml;
788
+ const resultsStart = span.start;
501
789
  const blockResults: ContentBlock[] = [];
790
+ const blockResultValues: ToolResult[] = [];
502
791
 
503
792
  // Parse result tags
504
793
  RESULT_REGEX.lastIndex = 0;
@@ -507,8 +796,7 @@ export function parseAccumulatedIntoBlocks(
507
796
  const toolUseId = resultMatch[1] ?? '';
508
797
  const content = unescapeXml(resultMatch[2] ?? '');
509
798
  pairedCallIds.add(toolUseId);
510
- const result: ToolResult = { toolUseId, content, isError: false };
511
- toolResults.push(result);
799
+ blockResultValues.push({ toolUseId, content, isError: false });
512
800
  blockResults.push({
513
801
  type: 'tool_result',
514
802
  toolUseId,
@@ -525,8 +813,7 @@ export function parseAccumulatedIntoBlocks(
525
813
  const toolUseId = errorMatch[1] ?? '';
526
814
  const content = unescapeXml(errorMatch[2] ?? '');
527
815
  pairedCallIds.add(toolUseId);
528
- const result: ToolResult = { toolUseId, content, isError: true };
529
- toolResults.push(result);
816
+ blockResultValues.push({ toolUseId, content, isError: true });
530
817
  blockResults.push({
531
818
  type: 'tool_result',
532
819
  toolUseId,
@@ -543,8 +830,8 @@ export function parseAccumulatedIntoBlocks(
543
830
  while ((legacyResultMatch = LEGACY_RESULT_REGEX.exec(innerContent)) !== null) {
544
831
  const toolName = legacyResultMatch[1]?.trim() || undefined;
545
832
  const content = unescapeXml(legacyResultMatch[2] ?? '');
546
- const toolUseId = claimCall(resultsMatch.index, toolName);
547
- toolResults.push({ toolUseId, toolName, content, isError: false });
833
+ const toolUseId = claimCall(resultsStart, toolName);
834
+ blockResultValues.push({ toolUseId, toolName, content, isError: false });
548
835
  blockResults.push({
549
836
  type: 'tool_result',
550
837
  toolUseId,
@@ -558,8 +845,8 @@ export function parseAccumulatedIntoBlocks(
558
845
  let legacyErrorMatch: RegExpExecArray | null;
559
846
  while ((legacyErrorMatch = LEGACY_ERROR_REGEX.exec(innerContent)) !== null) {
560
847
  const content = unescapeXml(legacyErrorMatch[1] ?? '');
561
- const toolUseId = claimCall(resultsMatch.index);
562
- toolResults.push({ toolUseId, content, isError: true });
848
+ const toolUseId = claimCall(resultsStart);
849
+ blockResultValues.push({ toolUseId, content, isError: true });
563
850
  blockResults.push({
564
851
  type: 'tool_result',
565
852
  toolUseId,
@@ -571,15 +858,19 @@ export function parseAccumulatedIntoBlocks(
571
858
 
572
859
  if (blockResults.length > 0) {
573
860
  positions.push({
574
- start: resultsMatch.index,
575
- end: resultsMatch.index + resultsMatch[0].length,
861
+ start: span.start,
862
+ end: span.end,
576
863
  block: blockResults,
864
+ results: blockResultValues,
577
865
  });
578
866
  }
579
867
  }
580
868
 
581
- // Sort positions by start index
582
- positions.sort((a, b) => a.start - b.start);
869
+ // Survivors are already in document order, so positions are too.
870
+ for (const pos of positions) {
871
+ if (pos.calls) toolCalls.push(...pos.calls);
872
+ if (pos.results) toolResults.push(...pos.results);
873
+ }
583
874
 
584
875
  // Build final blocks array, inserting text blocks between special blocks
585
876
  // Use processedText for slicing since positions are relative to it
@@ -600,7 +891,7 @@ export function parseAccumulatedIntoBlocks(
600
891
  blocks.push(pos.block);
601
892
  }
602
893
 
603
- lastEnd = pos.end;
894
+ lastEnd = Math.max(lastEnd, pos.end);
604
895
  }
605
896
 
606
897
  // Add any remaining text after the last special block
@@ -613,7 +904,15 @@ export function parseAccumulatedIntoBlocks(
613
904
  }
614
905
  }
615
906
 
616
- return { blocks, toolCalls, toolResults };
907
+ return {
908
+ blocks,
909
+ toolCalls,
910
+ toolResults,
911
+ unclosedToolBlock: endsWithPartialToolBlock(textOutsideSpans(processedText, survivingSpans)),
912
+ emptyToolBlocks,
913
+ splicedToolBlocks,
914
+ unclosedInvokeHeads,
915
+ };
617
916
  }
618
917
 
619
918
  // ============================================================================