@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
@@ -35,20 +35,140 @@ function parseParamValue(value) {
35
35
  return trimmed;
36
36
  }
37
37
  }
38
+ /**
39
+ * Parse the parameters of one invoke body. A self-closing invoke has no body
40
+ * and therefore no parameters.
41
+ */
42
+ function parseInvokeParameters(invokeBody) {
43
+ const input = {};
44
+ if (invokeBody === undefined)
45
+ return input;
46
+ PARAMETER_REGEX.lastIndex = 0;
47
+ let paramMatch;
48
+ while ((paramMatch = PARAMETER_REGEX.exec(invokeBody)) !== null) {
49
+ input[paramMatch[2] ?? ''] = parseParamValue(paramMatch[3] ?? '');
50
+ }
51
+ return input;
52
+ }
38
53
  // ============================================================================
39
54
  // Tool Call Parsing
40
55
  // ============================================================================
41
- // Regex patterns supporting both plain and antml: prefix
42
- // Pattern matches: <function_calls> or <function_calls>
43
- const FUNCTION_CALLS_REGEX = /<(antml:)?function_calls>([\s\S]*?)<\/(antml:)?function_calls>/g;
44
- // Full invoke tags with content
45
- const INVOKE_REGEX_FULL = /<(antml:)?invoke\s+name="([^"]+)">([\s\S]*?)<\/(antml:)?invoke>/g;
46
- // Self-closing invoke tags (no parameters)
47
- const INVOKE_REGEX_SELF_CLOSE = /<(antml:)?invoke\s+name="([^"]+)"\s*\/>/g;
56
+ // Invoke tags, both forms in ONE alternation so a block that mixes them keeps
57
+ // document order (two sequential passes appended full-then-self-closing).
58
+ // The name may be single- or double-quoted and whitespace may precede the
59
+ // closing angle bracket; the quote character is backreferenced so the opposite
60
+ // quote stays legal inside the name.
61
+ // The name is at least one character and cannot contain its own quote, so a
62
+ // nameless invoke and a stray second attribute both fail to match rather than
63
+ // yielding a garbage tool name — a block that parses to no invokes is reported.
64
+ // Groups: 1 = antml prefix, 2 = quote char, 3 = name, 4 = body (full form only).
65
+ const INVOKE_REGEX = /<(antml:)?invoke\s+name=(["'])((?:(?!\2).)+)\2\s*(?:\/>|>([\s\S]*?)<\/(antml:)?invoke>)/g;
66
+ const INVOKE_NAME_GROUP = 3;
67
+ const INVOKE_BODY_GROUP = 4;
68
+ const INVOKE_CLOSER_PREFIX_GROUP = 5;
69
+ // Same pattern without /g, for the re-anchor read that runs INSIDE the outer
70
+ // iteration: sharing the /g instance would clobber its lastIndex. Built from
71
+ // the one source so the two can never drift apart.
72
+ const INVOKE_REANCHOR_REGEX = new RegExp(INVOKE_REGEX.source);
73
+ // An invoke OPENER on its own — the containment test one level below the
74
+ // block fix, since an opener that never closed is invisible to INVOKE_REGEX.
75
+ const INVOKE_OPEN_REGEX = /<(antml:)?invoke\s+name=/g;
76
+ const INVOKE_CLOSE_TAG = '</invoke>';
77
+ /** Offsets of every invoke opener inside one matched invoke body. */
78
+ function invokeOpenerOffsets(invokeBody) {
79
+ const offsets = [];
80
+ INVOKE_OPEN_REGEX.lastIndex = 0;
81
+ let openerMatch;
82
+ while ((openerMatch = INVOKE_OPEN_REGEX.exec(invokeBody)) !== null) {
83
+ offsets.push(openerMatch.index);
84
+ }
85
+ return offsets;
86
+ }
87
+ function collectInvokes(innerContent) {
88
+ const calls = [];
89
+ let unclosedHeads = 0;
90
+ INVOKE_REGEX.lastIndex = 0;
91
+ let invokeMatch;
92
+ while ((invokeMatch = INVOKE_REGEX.exec(innerContent)) !== null) {
93
+ const invokeBody = invokeMatch[INVOKE_BODY_GROUP];
94
+ const swallowedOpenerOffsets = invokeBody === undefined ? [] : invokeOpenerOffsets(invokeBody);
95
+ if (swallowedOpenerOffsets.length === 0) {
96
+ calls.push({
97
+ name: invokeMatch[INVOKE_NAME_GROUP] ?? '',
98
+ input: parseInvokeParameters(invokeBody),
99
+ });
100
+ continue;
101
+ }
102
+ // Every opener in the body is a head that never closed, and so is the
103
+ // matched head itself; only the innermost one owns the closing tag.
104
+ unclosedHeads += swallowedOpenerOffsets.length;
105
+ const fullMatch = invokeMatch[0];
106
+ const closerLength = INVOKE_CLOSE_TAG.length + (invokeMatch[INVOKE_CLOSER_PREFIX_GROUP]?.length ?? 0);
107
+ const bodyOffset = fullMatch.length - closerLength - invokeBody.length;
108
+ const innermostOffset = swallowedOpenerOffsets[swallowedOpenerOffsets.length - 1];
109
+ const reanchoredMatch = INVOKE_REANCHOR_REGEX.exec(fullMatch.slice(bodyOffset + innermostOffset));
110
+ // A re-anchored head that still does not parse — a nameless invoke, say —
111
+ // is refused like any other: counted above, dispatched never.
112
+ if (reanchoredMatch) {
113
+ calls.push({
114
+ name: reanchoredMatch[INVOKE_NAME_GROUP] ?? '',
115
+ input: parseInvokeParameters(reanchoredMatch[INVOKE_BODY_GROUP]),
116
+ });
117
+ }
118
+ }
119
+ return { calls, unclosedHeads };
120
+ }
48
121
  // Parameter tags
49
122
  const PARAMETER_REGEX = /<(antml:)?parameter\s+name="([^"]+)">([\s\S]*?)<\/(antml:)?parameter>/g;
50
- // Check for function_results following a block
51
- const FUNCTION_RESULTS_START = /<(antml:)?function_results>/;
123
+ const FUNCTION_CALLS_OPEN_REGEX = /<(antml:)?function_calls>/g;
124
+ function resolveToolBlock(text, blockMatch) {
125
+ const end = blockMatch.index + blockMatch[0].length;
126
+ const innerContent = blockMatch[2] ?? '';
127
+ const innerStart = blockMatch.index + blockMatch[0].indexOf('>') + 1;
128
+ FUNCTION_CALLS_OPEN_REGEX.lastIndex = 0;
129
+ let innermostOpener = null;
130
+ let nestedMatch;
131
+ while ((nestedMatch = FUNCTION_CALLS_OPEN_REGEX.exec(innerContent)) !== null) {
132
+ innermostOpener = nestedMatch;
133
+ }
134
+ if (!innermostOpener) {
135
+ return {
136
+ start: blockMatch.index,
137
+ end,
138
+ innerContent,
139
+ fullMatch: blockMatch[0],
140
+ wasSpliced: false,
141
+ };
142
+ }
143
+ const reanchoredStart = innerStart + innermostOpener.index;
144
+ return {
145
+ start: reanchoredStart,
146
+ end,
147
+ innerContent: innerContent.slice(innermostOpener.index + innermostOpener[0].length),
148
+ fullMatch: text.slice(reanchoredStart, end),
149
+ wasSpliced: true,
150
+ };
151
+ }
152
+ // Openers that mark a block as already executed, when one is the very next
153
+ // token after it
154
+ const FUNCTION_RESULTS_START_ANCHORED = /^<(antml:)?function_results>/;
155
+ // Longest opener the anchored test can match, so the slice it reads is bounded
156
+ const FUNCTION_RESULTS_OPENER_MAX_LENGTH = '<function_results>'.length + 'antml:'.length;
157
+ const SINGLE_WHITESPACE_REGEX = /\s/;
158
+ /**
159
+ * Has this block already been executed? True only when the next NON-WHITESPACE
160
+ * token after it is a function_results opener. The previous test — "a
161
+ * function_results opener appears anywhere in the next 100 characters" — was
162
+ * wrong in both directions: padding past 100 characters re-selected a block
163
+ * that had already run, and a results block belonging to some later exchange
164
+ * marked a live call as spent.
165
+ */
166
+ function isFollowedByResults(text, afterPos) {
167
+ let scan = afterPos;
168
+ while (scan < text.length && SINGLE_WHITESPACE_REGEX.test(text[scan]))
169
+ scan++;
170
+ return FUNCTION_RESULTS_START_ANCHORED.test(text.slice(scan, scan + FUNCTION_RESULTS_OPENER_MAX_LENGTH));
171
+ }
52
172
  /**
53
173
  * Parse tool calls from text containing XML function_calls blocks
54
174
  *
@@ -56,67 +176,26 @@ const FUNCTION_RESULTS_START = /<(antml:)?function_results>/;
56
176
  * that doesn't have function_results immediately following it.
57
177
  */
58
178
  export function parseToolCalls(text) {
59
- // Reset regex
60
- FUNCTION_CALLS_REGEX.lastIndex = 0;
61
- // Find all function_calls blocks and pick the last unexecuted one
62
- let blockMatch = null;
63
- let lastUnexecutedMatch = null;
64
- while ((blockMatch = FUNCTION_CALLS_REGEX.exec(text)) !== null) {
65
- // Check if this block already has results after it
66
- const afterPos = blockMatch.index + blockMatch[0].length;
67
- const textAfter = text.slice(afterPos, afterPos + 100); // Check next 100 chars
68
- if (!FUNCTION_RESULTS_START.test(textAfter.trimStart())) {
69
- // This block hasn't been executed yet - store it
70
- // Need to capture all properties since exec returns are reused
71
- lastUnexecutedMatch = {
72
- ...blockMatch,
73
- index: blockMatch.index,
74
- input: blockMatch.input,
75
- };
179
+ // Pick the last unexecuted block among those that survive containment: a
180
+ // block quoted inside thinking or echoed in a tool result is content, and
181
+ // dispatching from it runs a call the model never made.
182
+ let lastUnexecutedBlock = null;
183
+ for (const block of collectLiveToolBlocks(text)) {
184
+ if (!isFollowedByResults(text, block.end)) {
185
+ lastUnexecutedBlock = block;
76
186
  }
77
187
  }
78
- if (!lastUnexecutedMatch) {
188
+ if (!lastUnexecutedBlock) {
79
189
  return null;
80
190
  }
81
- const fullMatch = lastUnexecutedMatch[0];
82
- const innerContent = lastUnexecutedMatch[2] ?? ''; // Group 2 is content between tags
83
- const matchIndex = lastUnexecutedMatch.index;
84
- const beforeText = text.slice(0, matchIndex);
85
- const afterText = text.slice(matchIndex + fullMatch.length);
86
- const calls = [];
87
- // Parse full invoke tags (with content and closing tag)
88
- INVOKE_REGEX_FULL.lastIndex = 0;
89
- let invokeMatch;
90
- while ((invokeMatch = INVOKE_REGEX_FULL.exec(innerContent)) !== null) {
91
- const toolName = invokeMatch[2] ?? ''; // Group 2 is the name
92
- const invokeContent = invokeMatch[3] ?? ''; // Group 3 is the content
93
- // Parse parameters
94
- const input = {};
95
- PARAMETER_REGEX.lastIndex = 0;
96
- let paramMatch;
97
- while ((paramMatch = PARAMETER_REGEX.exec(invokeContent)) !== null) {
98
- const paramName = paramMatch[2] ?? ''; // Group 2 is the name
99
- const paramValue = paramMatch[3] ?? ''; // Group 3 is the value
100
- // Parse value with special handling for large integers
101
- input[paramName] = parseParamValue(paramValue);
102
- }
103
- calls.push({
104
- id: generateToolId(),
105
- name: toolName,
106
- input,
107
- });
108
- }
109
- // Parse self-closing invoke tags (no parameters)
110
- INVOKE_REGEX_SELF_CLOSE.lastIndex = 0;
111
- let selfCloseMatch;
112
- while ((selfCloseMatch = INVOKE_REGEX_SELF_CLOSE.exec(innerContent)) !== null) {
113
- const toolName = selfCloseMatch[2] ?? ''; // Group 2 is the name
114
- calls.push({
115
- id: generateToolId(),
116
- name: toolName,
117
- input: {}, // No parameters for self-closing tag
118
- });
119
- }
191
+ const { innerContent, fullMatch } = lastUnexecutedBlock;
192
+ const beforeText = text.slice(0, lastUnexecutedBlock.start);
193
+ const afterText = text.slice(lastUnexecutedBlock.end);
194
+ const calls = collectInvokes(innerContent).calls.map((invoke) => ({
195
+ id: generateToolId(),
196
+ name: invoke.name,
197
+ input: invoke.input,
198
+ }));
120
199
  return {
121
200
  calls,
122
201
  beforeText,
@@ -277,6 +356,123 @@ export function formatToolDefinitions(tools) {
277
356
  // ============================================================================
278
357
  // Regex for matching thinking blocks (both plain and antml: prefixed)
279
358
  const THINKING_BLOCK_REGEX = /<(antml:)?thinking>([\s\S]*?)<\/(antml:)?thinking>/g;
359
+ /**
360
+ * Keep the outermost spans, in document order. RETAINED SPANS NEVER OVERLAP:
361
+ * a span whose START lies inside one already kept is refused, whether it ends
362
+ * inside that container or crosses out past it.
363
+ *
364
+ * Sorted by start with the outermost first on a tie, a span that begins before
365
+ * the furthest retained end begins INSIDE a container, and everything a
366
+ * container encloses is its content — the model wrote it, it did not call it.
367
+ * Testing only `end` let a crossing span through: a call opening inside a
368
+ * thinking block or a tool result and closing after it was retained alongside
369
+ * its container, so the quoted call dispatched and the overlapping source text
370
+ * was emitted twice — once as thinking content, once as a real tool_use — while
371
+ * the text cursor walked past the inner span, printing the container's own
372
+ * closing tag as visible model text. A dangling closer left downstream by such
373
+ * a refusal is what it looks like: ordinary text.
374
+ *
375
+ * The bound is unchanged: only a CLOSED container is visible here, so the
376
+ * streaming stop-sequence path, where the text is cut at `</function_calls>`
377
+ * before the enclosing `<thinking>` closes, is still outside this rule.
378
+ */
379
+ function retainOutermostSpans(spans) {
380
+ const sorted = [...spans].sort((a, b) => a.start - b.start || b.end - a.end);
381
+ const retained = [];
382
+ let furthestRetainedEnd = -1;
383
+ for (const span of sorted) {
384
+ if (span.start < furthestRetainedEnd)
385
+ continue;
386
+ retained.push(span);
387
+ furthestRetainedEnd = span.end;
388
+ }
389
+ return retained;
390
+ }
391
+ /**
392
+ * The text left once every retained span is cut out — the document's own
393
+ * structural level.
394
+ *
395
+ * Structural diagnostics read this rather than the raw text, because a tool
396
+ * tag QUOTED inside a thinking block or a tool result is content, not
397
+ * structure: counting it made a model that merely described an unclosed block
398
+ * indistinguishable from one whose block was truncated mid-write.
399
+ */
400
+ /**
401
+ * Every span the three block sweeps find, unfiltered and unregistered.
402
+ *
403
+ * Both parse entry points read the document through this one census, so the
404
+ * dispatch path and the block path can never disagree about which blocks are
405
+ * real.
406
+ */
407
+ function collectCandidateSpans(text) {
408
+ const spans = [];
409
+ THINKING_BLOCK_REGEX.lastIndex = 0;
410
+ let thinkingMatch;
411
+ while ((thinkingMatch = THINKING_BLOCK_REGEX.exec(text)) !== null) {
412
+ spans.push({
413
+ kind: 'thinking',
414
+ start: thinkingMatch.index,
415
+ end: thinkingMatch.index + thinkingMatch[0].length,
416
+ thinking: thinkingMatch[2] ?? '',
417
+ });
418
+ }
419
+ FUNCTION_BLOCK_WITH_CONTENT_REGEX.lastIndex = 0;
420
+ let funcMatch;
421
+ while ((funcMatch = FUNCTION_BLOCK_WITH_CONTENT_REGEX.exec(text)) !== null) {
422
+ // A spliced match (a stale opener joined to a later round's closer) is
423
+ // rejected as a block and re-anchored to its innermost opener; see
424
+ // resolveToolBlock. Containment reads the RESOLVED span, which is where
425
+ // the live block actually begins.
426
+ const resolvedBlock = resolveToolBlock(text, funcMatch);
427
+ spans.push({
428
+ kind: 'calls',
429
+ start: resolvedBlock.start,
430
+ end: resolvedBlock.end,
431
+ resolvedBlock,
432
+ });
433
+ }
434
+ FUNCTION_RESULTS_BLOCK_REGEX.lastIndex = 0;
435
+ let resultsMatch;
436
+ while ((resultsMatch = FUNCTION_RESULTS_BLOCK_REGEX.exec(text)) !== null) {
437
+ spans.push({
438
+ kind: 'results',
439
+ start: resultsMatch.index,
440
+ end: resultsMatch.index + resultsMatch[0].length,
441
+ innerContent: resultsMatch[2] ?? '',
442
+ // Verbatim document text the harness placed — carried for exact replay
443
+ // on the prefill path (membrane#36).
444
+ rawXml: resultsMatch[0],
445
+ });
446
+ }
447
+ return spans;
448
+ }
449
+ /**
450
+ * The function_calls blocks that are structure rather than content: those that
451
+ * survive containment, in document order.
452
+ *
453
+ * A block quoted inside a <thinking> block or echoed inside a tool result is
454
+ * text the model wrote ABOUT a call, not a call. Selecting from the raw sweep
455
+ * dispatched it — a model that named a tool and explicitly declined to use it
456
+ * had it executed anyway.
457
+ */
458
+ function collectLiveToolBlocks(text) {
459
+ const live = [];
460
+ for (const span of retainOutermostSpans(collectCandidateSpans(text))) {
461
+ if (span.kind === 'calls')
462
+ live.push(span.resolvedBlock);
463
+ }
464
+ return live;
465
+ }
466
+ function textOutsideSpans(text, spans) {
467
+ let residue = '';
468
+ let cursor = 0;
469
+ for (const span of spans) {
470
+ if (span.start > cursor)
471
+ residue += text.slice(cursor, span.start);
472
+ cursor = Math.max(cursor, span.end);
473
+ }
474
+ return residue + text.slice(cursor);
475
+ }
280
476
  // Regex for matching function_calls blocks with their content
281
477
  const FUNCTION_BLOCK_WITH_CONTENT_REGEX = /<(antml:)?function_calls>([\s\S]*?)<\/(antml:)?function_calls>/g;
282
478
  // Regex for matching function_results blocks with their content
@@ -314,6 +510,9 @@ export function parseAccumulatedIntoBlocks(text, options) {
314
510
  const blocks = [];
315
511
  const toolCalls = [];
316
512
  const toolResults = [];
513
+ let emptyToolBlocks = 0;
514
+ let splicedToolBlocks = 0;
515
+ let unclosedInvokeHeads = 0;
317
516
  const positions = [];
318
517
  // Call sites in document order, for pairing legacy-shaped results
319
518
  // (no tool_use_id on the wire) with the calls they answer.
@@ -332,91 +531,86 @@ export function parseAccumulatedIntoBlocks(text, options) {
332
531
  }
333
532
  return generateToolId();
334
533
  };
335
- // Find all thinking blocks
336
- THINKING_BLOCK_REGEX.lastIndex = 0;
337
- let thinkingMatch;
338
- while ((thinkingMatch = THINKING_BLOCK_REGEX.exec(processedText)) !== null) {
339
- positions.push({
340
- start: thinkingMatch.index,
341
- end: thinkingMatch.index + thinkingMatch[0].length,
342
- block: {
343
- type: 'thinking',
344
- thinking: thinkingMatch[2] ?? '',
345
- },
346
- });
347
- }
348
- // Find all function_calls blocks and parse their tool calls
349
- FUNCTION_BLOCK_WITH_CONTENT_REGEX.lastIndex = 0;
350
- let funcMatch;
351
- while ((funcMatch = FUNCTION_BLOCK_WITH_CONTENT_REGEX.exec(processedText)) !== null) {
352
- const innerContent = funcMatch[2] ?? '';
353
- // Verbatim document text of the whole block carried on each parsed
354
- // tool_use so prefill replay reproduces the generation exactly instead
355
- // of synthesizing a paraphrase (membrane#36).
356
- const rawXml = funcMatch[0];
357
- const blockToolCalls = [];
358
- // Parse invoke tags in this block
359
- INVOKE_REGEX_FULL.lastIndex = 0;
360
- let invokeMatch;
361
- while ((invokeMatch = INVOKE_REGEX_FULL.exec(innerContent)) !== null) {
362
- const toolName = invokeMatch[2] ?? '';
363
- const invokeContent = invokeMatch[3] ?? '';
364
- const input = {};
365
- // Parse parameters
366
- PARAMETER_REGEX.lastIndex = 0;
367
- let paramMatch;
368
- while ((paramMatch = PARAMETER_REGEX.exec(invokeContent)) !== null) {
369
- const paramName = paramMatch[2] ?? '';
370
- const paramValue = paramMatch[3] ?? '';
371
- // Parse value with special handling for large integers
372
- input[paramName] = parseParamValue(paramValue);
373
- }
374
- const id = generateToolId();
375
- const toolCall = { id, name: toolName, input };
376
- toolCalls.push(toolCall);
377
- callSites.push({ id, name: toolName, pos: funcMatch.index });
378
- blockToolCalls.push({
379
- type: 'tool_use',
380
- id,
381
- name: toolName,
382
- input,
383
- rawXml,
384
- });
385
- }
386
- // Parse self-closing invoke tags
387
- INVOKE_REGEX_SELF_CLOSE.lastIndex = 0;
388
- let selfCloseMatch;
389
- while ((selfCloseMatch = INVOKE_REGEX_SELF_CLOSE.exec(innerContent)) !== null) {
390
- const toolName = selfCloseMatch[2] ?? '';
391
- const id = generateToolId();
392
- const toolCall = { id, name: toolName, input: {} };
393
- toolCalls.push(toolCall);
394
- callSites.push({ id, name: toolName, pos: funcMatch.index });
395
- blockToolCalls.push({
396
- type: 'tool_use',
397
- id,
398
- name: toolName,
399
- input: {},
400
- rawXml,
401
- });
402
- }
403
- if (blockToolCalls.length > 0) {
534
+ // ── Pass 1: collect every candidate span, with NO side effects ───────────
535
+ //
536
+ // The three sweeps overlap by construction (a function_calls block quoted
537
+ // inside a thinking block is found by both), so nothing may be registered —
538
+ // no callSites entry, no legacy-result claim, no diagnostic count — until
539
+ // containment has decided which spans are real. Registering first and
540
+ // filtering after produced a call site that a legacy result could claim and
541
+ // that the filter then deleted, leaving a tool_result addressed to a
542
+ // tool_use no longer in the response.
543
+ const candidateSpans = collectCandidateSpans(processedText);
544
+ // ── Pass 2: containment ──────────────────────────────────────────────────
545
+ //
546
+ // Sorted by start, outermost span first on a tie: a span that BEGINS before
547
+ // the furthest retained end begins inside a container, so it is content of
548
+ // that container, not a sibling of it — retained spans never overlap, and a
549
+ // span that crosses out past its container is refused with the rest of the
550
+ // container's content. Zero-invoke and zero-result spans take part as
551
+ // containers even though they contribute no block of their own — whether a
552
+ // span holds anything parseable says nothing about whether it encloses the
553
+ // text beneath it.
554
+ const survivingSpans = retainOutermostSpans(candidateSpans);
555
+ // ── Pass 3: register the survivors, in document order ────────────────────
556
+ //
557
+ // One ordered walk, so every call site preceding a results block is already
558
+ // registered when that block claims — and no filtered span ever was.
559
+ for (const span of survivingSpans) {
560
+ if (span.kind === 'thinking') {
404
561
  positions.push({
405
- start: funcMatch.index,
406
- end: funcMatch.index + funcMatch[0].length,
407
- block: blockToolCalls,
562
+ start: span.start,
563
+ end: span.end,
564
+ block: { type: 'thinking', thinking: span.thinking },
408
565
  });
566
+ continue;
409
567
  }
410
- }
411
- // Find all function_results blocks and parse their results
412
- FUNCTION_RESULTS_BLOCK_REGEX.lastIndex = 0;
413
- let resultsMatch;
414
- while ((resultsMatch = FUNCTION_RESULTS_BLOCK_REGEX.exec(processedText)) !== null) {
415
- const innerContent = resultsMatch[2] ?? '';
416
- // Verbatim document text the harness placed — carried for exact replay
417
- // on the prefill path (membrane#36).
418
- const rawXml = resultsMatch[0];
568
+ if (span.kind === 'calls') {
569
+ const resolvedBlock = span.resolvedBlock;
570
+ if (resolvedBlock.wasSpliced)
571
+ splicedToolBlocks++;
572
+ // Verbatim document text of the whole block — carried on each parsed
573
+ // tool_use so prefill replay reproduces the generation exactly instead
574
+ // of synthesizing a paraphrase (membrane#36).
575
+ const rawXml = resolvedBlock.fullMatch;
576
+ const blockToolCalls = [];
577
+ const blockCalls = [];
578
+ // Parse invoke tags in this block (both forms, in document order); an
579
+ // invoke left open is refused and re-anchored to the call it swallowed.
580
+ const parsedInvokes = collectInvokes(resolvedBlock.innerContent);
581
+ unclosedInvokeHeads += parsedInvokes.unclosedHeads;
582
+ for (const invoke of parsedInvokes.calls) {
583
+ const toolName = invoke.name;
584
+ const input = invoke.input;
585
+ const id = generateToolId();
586
+ blockCalls.push({ id, name: toolName, input });
587
+ callSites.push({ id, name: toolName, pos: resolvedBlock.start });
588
+ blockToolCalls.push({
589
+ type: 'tool_use',
590
+ id,
591
+ name: toolName,
592
+ input,
593
+ rawXml,
594
+ });
595
+ }
596
+ if (blockToolCalls.length === 0) {
597
+ emptyToolBlocks++;
598
+ }
599
+ else {
600
+ positions.push({
601
+ start: resolvedBlock.start,
602
+ end: resolvedBlock.end,
603
+ block: blockToolCalls,
604
+ calls: blockCalls,
605
+ });
606
+ }
607
+ continue;
608
+ }
609
+ const innerContent = span.innerContent;
610
+ const rawXml = span.rawXml;
611
+ const resultsStart = span.start;
419
612
  const blockResults = [];
613
+ const blockResultValues = [];
420
614
  // Parse result tags
421
615
  RESULT_REGEX.lastIndex = 0;
422
616
  let resultMatch;
@@ -424,8 +618,7 @@ export function parseAccumulatedIntoBlocks(text, options) {
424
618
  const toolUseId = resultMatch[1] ?? '';
425
619
  const content = unescapeXml(resultMatch[2] ?? '');
426
620
  pairedCallIds.add(toolUseId);
427
- const result = { toolUseId, content, isError: false };
428
- toolResults.push(result);
621
+ blockResultValues.push({ toolUseId, content, isError: false });
429
622
  blockResults.push({
430
623
  type: 'tool_result',
431
624
  toolUseId,
@@ -441,8 +634,7 @@ export function parseAccumulatedIntoBlocks(text, options) {
441
634
  const toolUseId = errorMatch[1] ?? '';
442
635
  const content = unescapeXml(errorMatch[2] ?? '');
443
636
  pairedCallIds.add(toolUseId);
444
- const result = { toolUseId, content, isError: true };
445
- toolResults.push(result);
637
+ blockResultValues.push({ toolUseId, content, isError: true });
446
638
  blockResults.push({
447
639
  type: 'tool_result',
448
640
  toolUseId,
@@ -458,8 +650,8 @@ export function parseAccumulatedIntoBlocks(text, options) {
458
650
  while ((legacyResultMatch = LEGACY_RESULT_REGEX.exec(innerContent)) !== null) {
459
651
  const toolName = legacyResultMatch[1]?.trim() || undefined;
460
652
  const content = unescapeXml(legacyResultMatch[2] ?? '');
461
- const toolUseId = claimCall(resultsMatch.index, toolName);
462
- toolResults.push({ toolUseId, toolName, content, isError: false });
653
+ const toolUseId = claimCall(resultsStart, toolName);
654
+ blockResultValues.push({ toolUseId, toolName, content, isError: false });
463
655
  blockResults.push({
464
656
  type: 'tool_result',
465
657
  toolUseId,
@@ -473,8 +665,8 @@ export function parseAccumulatedIntoBlocks(text, options) {
473
665
  let legacyErrorMatch;
474
666
  while ((legacyErrorMatch = LEGACY_ERROR_REGEX.exec(innerContent)) !== null) {
475
667
  const content = unescapeXml(legacyErrorMatch[1] ?? '');
476
- const toolUseId = claimCall(resultsMatch.index);
477
- toolResults.push({ toolUseId, content, isError: true });
668
+ const toolUseId = claimCall(resultsStart);
669
+ blockResultValues.push({ toolUseId, content, isError: true });
478
670
  blockResults.push({
479
671
  type: 'tool_result',
480
672
  toolUseId,
@@ -485,14 +677,20 @@ export function parseAccumulatedIntoBlocks(text, options) {
485
677
  }
486
678
  if (blockResults.length > 0) {
487
679
  positions.push({
488
- start: resultsMatch.index,
489
- end: resultsMatch.index + resultsMatch[0].length,
680
+ start: span.start,
681
+ end: span.end,
490
682
  block: blockResults,
683
+ results: blockResultValues,
491
684
  });
492
685
  }
493
686
  }
494
- // Sort positions by start index
495
- positions.sort((a, b) => a.start - b.start);
687
+ // Survivors are already in document order, so positions are too.
688
+ for (const pos of positions) {
689
+ if (pos.calls)
690
+ toolCalls.push(...pos.calls);
691
+ if (pos.results)
692
+ toolResults.push(...pos.results);
693
+ }
496
694
  // Build final blocks array, inserting text blocks between special blocks
497
695
  // Use processedText for slicing since positions are relative to it
498
696
  let lastEnd = 0;
@@ -511,7 +709,7 @@ export function parseAccumulatedIntoBlocks(text, options) {
511
709
  else {
512
710
  blocks.push(pos.block);
513
711
  }
514
- lastEnd = pos.end;
712
+ lastEnd = Math.max(lastEnd, pos.end);
515
713
  }
516
714
  // Add any remaining text after the last special block
517
715
  // This also handles the case where there are no special blocks at all
@@ -522,7 +720,15 @@ export function parseAccumulatedIntoBlocks(text, options) {
522
720
  blocks.push({ type: 'text', text: textContent });
523
721
  }
524
722
  }
525
- return { blocks, toolCalls, toolResults };
723
+ return {
724
+ blocks,
725
+ toolCalls,
726
+ toolResults,
727
+ unclosedToolBlock: endsWithPartialToolBlock(textOutsideSpans(processedText, survivingSpans)),
728
+ emptyToolBlocks,
729
+ splicedToolBlocks,
730
+ unclosedInvokeHeads,
731
+ };
526
732
  }
527
733
  // Assembled to avoid triggering stop sequences in model output
528
734
  const FUNC_CALLS_OPEN = '<' + 'function_calls>';