@animalabs/membrane 0.5.80 → 0.5.82
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cache-wire-receipt.d.ts +13 -0
- package/dist/cache-wire-receipt.d.ts.map +1 -0
- package/dist/cache-wire-receipt.js +108 -0
- package/dist/cache-wire-receipt.js.map +1 -0
- package/dist/context/index.d.ts +1 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +1 -1
- package/dist/context/index.js.map +1 -1
- package/dist/context/process.d.ts +3 -1
- package/dist/context/process.d.ts.map +1 -1
- package/dist/context/process.js +230 -49
- package/dist/context/process.js.map +1 -1
- package/dist/context/types.d.ts +30 -0
- package/dist/context/types.d.ts.map +1 -1
- package/dist/context/types.js +16 -0
- package/dist/context/types.js.map +1 -1
- package/dist/floating-cache-marker.test.js +21 -0
- package/dist/floating-cache-marker.test.js.map +1 -1
- package/dist/formatters/anthropic-xml.d.ts +2 -0
- package/dist/formatters/anthropic-xml.d.ts.map +1 -1
- package/dist/formatters/anthropic-xml.js +64 -23
- package/dist/formatters/anthropic-xml.js.map +1 -1
- package/dist/formatters/native.d.ts.map +1 -1
- package/dist/formatters/native.js +16 -3
- package/dist/formatters/native.js.map +1 -1
- package/dist/formatters/normalize-tool-pairs.d.ts +7 -0
- package/dist/formatters/normalize-tool-pairs.d.ts.map +1 -1
- package/dist/formatters/normalize-tool-pairs.js +45 -0
- package/dist/formatters/normalize-tool-pairs.js.map +1 -1
- package/dist/formatters/types.d.ts +31 -0
- package/dist/formatters/types.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/membrane.d.ts +137 -8
- package/dist/membrane.d.ts.map +1 -1
- package/dist/membrane.js +823 -310
- package/dist/membrane.js.map +1 -1
- package/dist/providers/anthropic.d.ts +37 -0
- package/dist/providers/anthropic.d.ts.map +1 -1
- package/dist/providers/anthropic.js +142 -25
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/bedrock.d.ts +8 -0
- package/dist/providers/bedrock.d.ts.map +1 -1
- package/dist/providers/bedrock.js +24 -1
- package/dist/providers/bedrock.js.map +1 -1
- package/dist/providers/gemini.d.ts +11 -0
- package/dist/providers/gemini.d.ts.map +1 -1
- package/dist/providers/gemini.js +104 -82
- package/dist/providers/gemini.js.map +1 -1
- package/dist/providers/index.d.ts +1 -1
- package/dist/providers/index.d.ts.map +1 -1
- package/dist/providers/index.js.map +1 -1
- package/dist/providers/mock.d.ts +2 -0
- package/dist/providers/mock.d.ts.map +1 -1
- package/dist/providers/mock.js +2 -0
- package/dist/providers/mock.js.map +1 -1
- package/dist/providers/openai-compatible.d.ts +7 -0
- package/dist/providers/openai-compatible.d.ts.map +1 -1
- package/dist/providers/openai-compatible.js +99 -48
- package/dist/providers/openai-compatible.js.map +1 -1
- package/dist/providers/openai-completions.d.ts +5 -0
- package/dist/providers/openai-completions.d.ts.map +1 -1
- package/dist/providers/openai-completions.js +107 -44
- package/dist/providers/openai-completions.js.map +1 -1
- package/dist/providers/openai-responses-api.d.ts +16 -0
- package/dist/providers/openai-responses-api.d.ts.map +1 -1
- package/dist/providers/openai-responses-api.js +38 -10
- package/dist/providers/openai-responses-api.js.map +1 -1
- package/dist/providers/openai-responses.d.ts +2 -0
- package/dist/providers/openai-responses.d.ts.map +1 -1
- package/dist/providers/openai-responses.js +8 -1
- package/dist/providers/openai-responses.js.map +1 -1
- package/dist/providers/openai.d.ts +7 -0
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +92 -44
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/openrouter.d.ts +9 -0
- package/dist/providers/openrouter.d.ts.map +1 -1
- package/dist/providers/openrouter.js +118 -62
- package/dist/providers/openrouter.js.map +1 -1
- package/dist/providers/utils.d.ts +74 -2
- package/dist/providers/utils.d.ts.map +1 -1
- package/dist/providers/utils.js +184 -3
- package/dist/providers/utils.js.map +1 -1
- package/dist/registry/default-pricing.d.ts +22 -0
- package/dist/registry/default-pricing.d.ts.map +1 -1
- package/dist/registry/default-pricing.js +162 -28
- package/dist/registry/default-pricing.js.map +1 -1
- package/dist/types/content.d.ts +13 -0
- package/dist/types/content.d.ts.map +1 -1
- package/dist/types/content.js.map +1 -1
- package/dist/types/errors.d.ts +15 -0
- package/dist/types/errors.d.ts.map +1 -1
- package/dist/types/errors.js +26 -0
- package/dist/types/errors.js.map +1 -1
- package/dist/types/index.d.ts +3 -3
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +1 -1
- package/dist/types/index.js.map +1 -1
- package/dist/types/provider.d.ts +50 -0
- package/dist/types/provider.d.ts.map +1 -1
- package/dist/types/request.d.ts +6 -0
- package/dist/types/request.d.ts.map +1 -1
- package/dist/types/response.d.ts +85 -4
- package/dist/types/response.d.ts.map +1 -1
- package/dist/types/response.js.map +1 -1
- package/dist/types/streaming.d.ts +6 -1
- package/dist/types/streaming.d.ts.map +1 -1
- package/dist/utils/cache-marker-budget.d.ts +91 -0
- package/dist/utils/cache-marker-budget.d.ts.map +1 -0
- package/dist/utils/cache-marker-budget.js +208 -0
- package/dist/utils/cache-marker-budget.js.map +1 -0
- package/dist/utils/cost.d.ts +22 -0
- package/dist/utils/cost.d.ts.map +1 -1
- package/dist/utils/cost.js +55 -0
- package/dist/utils/cost.js.map +1 -1
- package/dist/utils/thinking-carriers.d.ts +80 -0
- package/dist/utils/thinking-carriers.d.ts.map +1 -0
- package/dist/utils/thinking-carriers.js +115 -0
- package/dist/utils/thinking-carriers.js.map +1 -0
- package/dist/utils/tool-parser.d.ts +24 -0
- package/dist/utils/tool-parser.d.ts.map +1 -1
- package/dist/utils/tool-parser.js +367 -161
- package/dist/utils/tool-parser.js.map +1 -1
- package/dist/utils/usage.d.ts +71 -0
- package/dist/utils/usage.d.ts.map +1 -0
- package/dist/utils/usage.js +161 -0
- package/dist/utils/usage.js.map +1 -0
- package/dist/yielding-stream.d.ts +8 -0
- package/dist/yielding-stream.d.ts.map +1 -1
- package/dist/yielding-stream.js +49 -3
- package/dist/yielding-stream.js.map +1 -1
- package/package.json +1 -1
- package/src/cache-wire-receipt.ts +125 -0
- package/src/context/index.ts +1 -0
- package/src/context/process.ts +305 -59
- package/src/context/types.ts +38 -0
- package/src/floating-cache-marker.test.ts +24 -0
- package/src/formatters/anthropic-xml.ts +66 -23
- package/src/formatters/native.ts +23 -2
- package/src/formatters/normalize-tool-pairs.ts +49 -0
- package/src/formatters/types.ts +35 -0
- package/src/index.ts +6 -0
- package/src/membrane.ts +974 -313
- package/src/providers/anthropic.ts +173 -19
- package/src/providers/bedrock.ts +25 -1
- package/src/providers/gemini.ts +125 -87
- package/src/providers/index.ts +1 -0
- package/src/providers/mock.ts +3 -0
- package/src/providers/openai-compatible.ts +102 -45
- package/src/providers/openai-completions.ts +112 -46
- package/src/providers/openai-responses-api.ts +45 -15
- package/src/providers/openai-responses.ts +8 -1
- package/src/providers/openai.ts +95 -42
- package/src/providers/openrouter.ts +125 -63
- package/src/providers/utils.ts +227 -6
- package/src/registry/default-pricing.ts +165 -28
- package/src/types/content.ts +14 -0
- package/src/types/errors.ts +28 -0
- package/src/types/index.ts +6 -0
- package/src/types/provider.ts +55 -0
- package/src/types/request.ts +7 -0
- package/src/types/response.ts +109 -8
- package/src/types/streaming.ts +8 -2
- package/src/utils/cache-marker-budget.ts +237 -0
- package/src/utils/cost.ts +64 -0
- package/src/utils/thinking-carriers.ts +125 -0
- package/src/utils/tool-parser.ts +473 -174
- package/src/utils/usage.ts +184 -0
- package/src/yielding-stream.ts +54 -3
|
@@ -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
|
-
//
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
//
|
|
47
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
//
|
|
60
|
-
|
|
61
|
-
//
|
|
62
|
-
let
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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 (!
|
|
188
|
+
if (!lastUnexecutedBlock) {
|
|
79
189
|
return null;
|
|
80
190
|
}
|
|
81
|
-
const fullMatch =
|
|
82
|
-
const
|
|
83
|
-
const
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
//
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
//
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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:
|
|
406
|
-
end:
|
|
407
|
-
block:
|
|
562
|
+
start: span.start,
|
|
563
|
+
end: span.end,
|
|
564
|
+
block: { type: 'thinking', thinking: span.thinking },
|
|
408
565
|
});
|
|
566
|
+
continue;
|
|
409
567
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
462
|
-
|
|
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(
|
|
477
|
-
|
|
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:
|
|
489
|
-
end:
|
|
680
|
+
start: span.start,
|
|
681
|
+
end: span.end,
|
|
490
682
|
block: blockResults,
|
|
683
|
+
results: blockResultValues,
|
|
491
684
|
});
|
|
492
685
|
}
|
|
493
686
|
}
|
|
494
|
-
//
|
|
495
|
-
|
|
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 {
|
|
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>';
|