@animalabs/membrane 0.5.78 → 0.5.79
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/formatters/anthropic-xml.d.ts.map +1 -1
- package/dist/formatters/anthropic-xml.js +30 -2
- package/dist/formatters/anthropic-xml.js.map +1 -1
- package/dist/membrane.d.ts.map +1 -1
- package/dist/membrane.js +34 -0
- package/dist/membrane.js.map +1 -1
- package/dist/providers/anthropic.d.ts +9 -0
- package/dist/providers/anthropic.d.ts.map +1 -1
- package/dist/providers/anthropic.js +1 -1
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/bedrock.d.ts.map +1 -1
- package/dist/providers/bedrock.js +31 -2
- package/dist/providers/bedrock.js.map +1 -1
- package/dist/types/content.d.ts +5 -0
- package/dist/types/content.d.ts.map +1 -1
- package/dist/types/content.js.map +1 -1
- package/dist/types/tools.d.ts +19 -0
- package/dist/types/tools.d.ts.map +1 -1
- package/dist/utils/tool-parser.d.ts +16 -2
- package/dist/utils/tool-parser.d.ts.map +1 -1
- package/dist/utils/tool-parser.js +143 -35
- package/dist/utils/tool-parser.js.map +1 -1
- package/package.json +1 -1
- package/src/formatters/anthropic-xml.ts +30 -2
- package/src/membrane.ts +36 -0
- package/src/providers/anthropic.ts +1 -1
- package/src/providers/bedrock.ts +32 -1
- package/src/types/content.ts +5 -0
- package/src/types/tools.ts +20 -0
- package/src/utils/tool-parser.ts +147 -36
package/package.json
CHANGED
|
@@ -104,6 +104,7 @@ function toToolResult(block: ToolResultContent): ToolResult {
|
|
|
104
104
|
}
|
|
105
105
|
return {
|
|
106
106
|
toolUseId: block.toolUseId,
|
|
107
|
+
toolName: block.toolName,
|
|
107
108
|
content,
|
|
108
109
|
isError: block.isError ?? false,
|
|
109
110
|
};
|
|
@@ -160,6 +161,9 @@ export class AnthropicXmlFormatter implements PrefillFormatter {
|
|
|
160
161
|
// Track conversation state
|
|
161
162
|
let currentConversation: string[] = [];
|
|
162
163
|
let lastNonEmptyParticipant: string | null = null;
|
|
164
|
+
// True right after an unlabeled tool-results glue — the next assistant
|
|
165
|
+
// message continues the same turn, so it must not get a fresh label.
|
|
166
|
+
let lastWasToolResults = false;
|
|
163
167
|
|
|
164
168
|
// Track cache markers applied
|
|
165
169
|
let cacheMarkersApplied = 0;
|
|
@@ -298,15 +302,39 @@ export class AnthropicXmlFormatter implements PrefillFormatter {
|
|
|
298
302
|
const isBotMessage = message.participant === assistantParticipant;
|
|
299
303
|
const isContinuation = isBotMessage && lastNonEmptyParticipant === assistantParticipant && !hasToolResult;
|
|
300
304
|
|
|
305
|
+
const isPureToolResults =
|
|
306
|
+
hasToolResult && message.content.every((c) => c.type === 'tool_result');
|
|
307
|
+
|
|
301
308
|
if (isContinuation && isLastMessage) {
|
|
302
309
|
// Bot continuation - don't add prefix
|
|
303
310
|
continue;
|
|
304
311
|
} else if (isLastMessage && isEmpty) {
|
|
305
312
|
// Completion target - prefix added below
|
|
306
313
|
} else if (text) {
|
|
307
|
-
|
|
308
|
-
|
|
314
|
+
if (isPureToolResults) {
|
|
315
|
+
// Tool results are not speech: replay them exactly as they were
|
|
316
|
+
// injected live — inside the assistant flow, unlabeled (legacy
|
|
317
|
+
// convention). A participant prefix here re-attributes the
|
|
318
|
+
// harness's injection as someone's utterance, and the model then
|
|
319
|
+
// reads the same result in two attributions across compiles
|
|
320
|
+
// (D2 of the Evander 2026-08-08 scaffold-leak analysis).
|
|
321
|
+
currentConversation.push(`${text}${this.config.messageDelimiter}`);
|
|
322
|
+
lastWasToolResults = true;
|
|
323
|
+
} else if (isBotMessage && lastWasToolResults) {
|
|
324
|
+
// The round after injected results continues the same assistant
|
|
325
|
+
// turn — no fresh label mid-turn, matching what the model lived.
|
|
326
|
+
// (If a turn ended exactly on a results injection, the next
|
|
327
|
+
// assistant turn glues here unlabeled — a minor cost the message
|
|
328
|
+
// model can't distinguish; a turn id would be needed.)
|
|
329
|
+
currentConversation.push(`${text}${this.config.messageDelimiter}`);
|
|
330
|
+
lastWasToolResults = false;
|
|
309
331
|
lastNonEmptyParticipant = message.participant;
|
|
332
|
+
} else {
|
|
333
|
+
currentConversation.push(`${message.participant}: ${text}${this.config.messageDelimiter}`);
|
|
334
|
+
lastWasToolResults = false;
|
|
335
|
+
if (!hasToolResult) {
|
|
336
|
+
lastNonEmptyParticipant = message.participant;
|
|
337
|
+
}
|
|
310
338
|
}
|
|
311
339
|
}
|
|
312
340
|
|
package/src/membrane.ts
CHANGED
|
@@ -408,10 +408,16 @@ export class Membrane {
|
|
|
408
408
|
// Track the initial prefill length so we can extract only NEW content for response
|
|
409
409
|
// Also track what block type we're inside at the end of prefill
|
|
410
410
|
let initialPrefillLength = 0;
|
|
411
|
+
// Watermark for per-round delta text (ToolContext.roundPreamble): start of
|
|
412
|
+
// the CURRENT round's model text in parser-accumulated coordinates.
|
|
413
|
+
// Advanced past each round's injected results push, so injected
|
|
414
|
+
// <function_results> XML never enters a round's delta.
|
|
415
|
+
let roundStartLen = 0;
|
|
411
416
|
let initialBlockType: 'thinking' | 'tool_call' | 'tool_result' | null = null;
|
|
412
417
|
if (prefillResult.assistantPrefill) {
|
|
413
418
|
parser.push(prefillResult.assistantPrefill);
|
|
414
419
|
initialPrefillLength = prefillResult.assistantPrefill.length;
|
|
420
|
+
roundStartLen = initialPrefillLength;
|
|
415
421
|
// Capture what block type we're inside after prefill (if any)
|
|
416
422
|
if (parser.isInsideBlock()) {
|
|
417
423
|
const blockType = parser.getCurrentBlockType();
|
|
@@ -699,6 +705,7 @@ export class Membrane {
|
|
|
699
705
|
const context: ToolContext = {
|
|
700
706
|
rawText: parsed.fullMatch,
|
|
701
707
|
preamble: parsed.beforeText.slice(initialPrefillLength),
|
|
708
|
+
roundPreamble: parsed.beforeText.slice(roundStartLen),
|
|
702
709
|
depth: toolDepth,
|
|
703
710
|
previousResults: executedToolResults,
|
|
704
711
|
accumulated: parser.getAccumulated().slice(initialPrefillLength),
|
|
@@ -711,6 +718,14 @@ export class Membrane {
|
|
|
711
718
|
);
|
|
712
719
|
}
|
|
713
720
|
|
|
721
|
+
// Backfill tool names for the legacy XML result rendering
|
|
722
|
+
// (<result><tool_name>…</tool_name><stdout>…) when the executor
|
|
723
|
+
// didn't supply them.
|
|
724
|
+
const callNames = new Map(parsed.calls.map((c) => [c.id, c.name]));
|
|
725
|
+
for (const r of results) {
|
|
726
|
+
if (!r.toolName) r.toolName = callNames.get(r.toolUseId);
|
|
727
|
+
}
|
|
728
|
+
|
|
714
729
|
// Track the tool results
|
|
715
730
|
executedToolResults.push(...results);
|
|
716
731
|
|
|
@@ -832,6 +847,10 @@ export class Membrane {
|
|
|
832
847
|
);
|
|
833
848
|
}
|
|
834
849
|
|
|
850
|
+
// Next round's model text starts after everything injected this
|
|
851
|
+
// round (results XML, image-split tags, thinking opener).
|
|
852
|
+
roundStartLen = parser.getAccumulated().length;
|
|
853
|
+
|
|
835
854
|
// Reset parser state for new streaming iteration. Tool rounds
|
|
836
855
|
// are the caller's work — they count against maxToolDepth only,
|
|
837
856
|
// never against the resumption guards (issue #39 review).
|
|
@@ -2314,10 +2333,14 @@ export class Membrane {
|
|
|
2314
2333
|
|
|
2315
2334
|
// Initialize parser with prefill content
|
|
2316
2335
|
let initialPrefillLength = 0;
|
|
2336
|
+
// Watermark for per-round delta text (ToolContext.roundPreamble) — see
|
|
2337
|
+
// streamWithXmlTools for rationale. Advanced past each results push.
|
|
2338
|
+
let roundStartLen = 0;
|
|
2317
2339
|
let initialBlockType: 'thinking' | 'tool_call' | 'tool_result' | null = null;
|
|
2318
2340
|
if (prefillResult.assistantPrefill) {
|
|
2319
2341
|
parser.push(prefillResult.assistantPrefill);
|
|
2320
2342
|
initialPrefillLength = prefillResult.assistantPrefill.length;
|
|
2343
|
+
roundStartLen = initialPrefillLength;
|
|
2321
2344
|
if (parser.isInsideBlock()) {
|
|
2322
2345
|
const blockType = parser.getCurrentBlockType();
|
|
2323
2346
|
if (blockType === 'thinking' || blockType === 'tool_call' || blockType === 'tool_result') {
|
|
@@ -2564,6 +2587,7 @@ export class Membrane {
|
|
|
2564
2587
|
const context: ToolContext = {
|
|
2565
2588
|
rawText: parsed.fullMatch,
|
|
2566
2589
|
preamble: parsed.beforeText.slice(initialPrefillLength),
|
|
2590
|
+
roundPreamble: parsed.beforeText.slice(roundStartLen),
|
|
2567
2591
|
depth: toolDepth,
|
|
2568
2592
|
previousResults: executedToolResults,
|
|
2569
2593
|
accumulated: parser.getAccumulated().slice(initialPrefillLength),
|
|
@@ -2578,6 +2602,14 @@ export class Membrane {
|
|
|
2578
2602
|
|
|
2579
2603
|
const { results, injectedMessages } = await stream.requestToolExecution(toolCallsEvent);
|
|
2580
2604
|
|
|
2605
|
+
// Backfill tool names for the legacy XML result rendering
|
|
2606
|
+
// (<result><tool_name>…</tool_name><stdout>…) when the executor
|
|
2607
|
+
// didn't supply them.
|
|
2608
|
+
const yieldCallNames = new Map(parsed.calls.map((c) => [c.id, c.name]));
|
|
2609
|
+
for (const r of results) {
|
|
2610
|
+
if (!r.toolName) r.toolName = yieldCallNames.get(r.toolUseId);
|
|
2611
|
+
}
|
|
2612
|
+
|
|
2581
2613
|
// Mid-turn injected messages are not supported on the XML prefill
|
|
2582
2614
|
// path: the continuation is an assistant prefill over an XML
|
|
2583
2615
|
// transcript, not a message array, so there is no user envelope
|
|
@@ -2725,6 +2757,10 @@ export class Membrane {
|
|
|
2725
2757
|
);
|
|
2726
2758
|
}
|
|
2727
2759
|
|
|
2760
|
+
// Next round's model text starts after everything injected this
|
|
2761
|
+
// round (results XML, image-split tags, thinking opener).
|
|
2762
|
+
roundStartLen = parser.getAccumulated().length;
|
|
2763
|
+
|
|
2728
2764
|
// Tool rounds are the caller's work — they count against
|
|
2729
2765
|
// maxToolDepth only, never against the resumption guards
|
|
2730
2766
|
// (issue #39 review: the uncapped tool-loop contract stands).
|
|
@@ -780,7 +780,7 @@ function toAnthropicToolResultContent(
|
|
|
780
780
|
* can lose or mislabel mediaType (e.g. a PNG tagged image/jpeg), which the
|
|
781
781
|
* Anthropic API rejects with a 400. Trust the bytes; fall back to the declared
|
|
782
782
|
* type, then jpeg. */
|
|
783
|
-
function detectImageMediaType(data: string | undefined, fallback?: string): string {
|
|
783
|
+
export function detectImageMediaType(data: string | undefined, fallback?: string): string {
|
|
784
784
|
try {
|
|
785
785
|
const b = Buffer.from((data || "").slice(0, 24), "base64");
|
|
786
786
|
if (b[0]===0x89&&b[1]===0x50&&b[2]===0x4e&&b[3]===0x47) return "image/png";
|
package/src/providers/bedrock.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
INTERLEAVED_THINKING_BETA,
|
|
26
26
|
needsInterleavedThinkingBeta,
|
|
27
27
|
thinkingEnabled,
|
|
28
|
+
detectImageMediaType,
|
|
28
29
|
} from './anthropic.js';
|
|
29
30
|
|
|
30
31
|
// ============================================================================
|
|
@@ -404,6 +405,25 @@ export class BedrockAdapter implements ProviderAdapter {
|
|
|
404
405
|
// caching works without the field, so strip just the ttl and keep the
|
|
405
406
|
// breakpoint. Transport quirks belong to the transport, not to every
|
|
406
407
|
// caller that sets cacheTtl. (Connectome issue #35.)
|
|
408
|
+
// Bedrock is strict about the wire shape of image sources: internal blocks
|
|
409
|
+
// carry camelCase `mediaType`, the API requires snake_case `media_type`.
|
|
410
|
+
// The Anthropic adapter converts on both paths (toAnthropicContent /
|
|
411
|
+
// toAnthropicToolResultContent); without the same conversion here a
|
|
412
|
+
// tool-returned image 400s with "media_type: Field required" mid-turn
|
|
413
|
+
// (observed on eidoverse snapshot results, 2026-08-08). Applies to
|
|
414
|
+
// top-level image blocks AND images nested inside tool_result content.
|
|
415
|
+
const toWireImage = (block: any): any => {
|
|
416
|
+
const source = block.source;
|
|
417
|
+
if (!source || source.type !== 'base64') return block;
|
|
418
|
+
const { mediaType, media_type, ...restSource } = source;
|
|
419
|
+
return {
|
|
420
|
+
...block,
|
|
421
|
+
source: {
|
|
422
|
+
...restSource,
|
|
423
|
+
media_type: detectImageMediaType(source.data, (media_type ?? mediaType) as string),
|
|
424
|
+
},
|
|
425
|
+
};
|
|
426
|
+
};
|
|
407
427
|
const sanitizedMessages = (request.messages as any[]).map((msg: any) => {
|
|
408
428
|
if (!Array.isArray(msg.content)) return msg;
|
|
409
429
|
return {
|
|
@@ -411,7 +431,18 @@ export class BedrockAdapter implements ProviderAdapter {
|
|
|
411
431
|
content: msg.content.map((block: any) => {
|
|
412
432
|
if (block.type === 'image' && block.sourceUrl !== undefined) {
|
|
413
433
|
const { sourceUrl, ...rest } = block;
|
|
414
|
-
return stripCacheTtl(rest);
|
|
434
|
+
return stripCacheTtl(toWireImage(rest));
|
|
435
|
+
}
|
|
436
|
+
if (block.type === 'image') {
|
|
437
|
+
return stripCacheTtl(toWireImage(block));
|
|
438
|
+
}
|
|
439
|
+
if (block.type === 'tool_result' && Array.isArray(block.content)) {
|
|
440
|
+
return stripCacheTtl({
|
|
441
|
+
...block,
|
|
442
|
+
content: block.content.map((inner: any) =>
|
|
443
|
+
inner?.type === 'image' ? toWireImage(inner) : inner,
|
|
444
|
+
),
|
|
445
|
+
});
|
|
415
446
|
}
|
|
416
447
|
return stripCacheTtl(block);
|
|
417
448
|
}),
|
package/src/types/content.ts
CHANGED
|
@@ -128,6 +128,11 @@ export interface ToolUseContent {
|
|
|
128
128
|
export interface ToolResultContent {
|
|
129
129
|
type: 'tool_result';
|
|
130
130
|
toolUseId: string;
|
|
131
|
+
/**
|
|
132
|
+
* Tool name, persisted so XML replay can reconstruct the legacy
|
|
133
|
+
* `<tool_name>` element byte-identically to the live injection.
|
|
134
|
+
*/
|
|
135
|
+
toolName?: string;
|
|
131
136
|
content: string | ContentBlock[];
|
|
132
137
|
isError?: boolean;
|
|
133
138
|
/**
|
package/src/types/tools.ts
CHANGED
|
@@ -37,6 +37,13 @@ export interface ToolCall {
|
|
|
37
37
|
|
|
38
38
|
export interface ToolResult {
|
|
39
39
|
toolUseId: string;
|
|
40
|
+
/**
|
|
41
|
+
* Tool name, for the legacy XML result rendering
|
|
42
|
+
* (`<result><tool_name>…</tool_name><stdout>…</stdout></result>`).
|
|
43
|
+
* Optional: XML paths backfill it from the round's parsed calls when the
|
|
44
|
+
* executor didn't supply it.
|
|
45
|
+
*/
|
|
46
|
+
toolName?: string;
|
|
40
47
|
/**
|
|
41
48
|
* Result content - can be string or structured content blocks (for images).
|
|
42
49
|
* For XML mode, images are noted in text. For native mode, passed as content blocks.
|
|
@@ -63,6 +70,19 @@ export interface ToolContext {
|
|
|
63
70
|
/** Text before the tool calls (already streamed to user) */
|
|
64
71
|
preamble: string;
|
|
65
72
|
|
|
73
|
+
/**
|
|
74
|
+
* XML mode only: THIS round's model-authored text — the slice of the
|
|
75
|
+
* turn between the end of the previous round's injected results and this
|
|
76
|
+
* round's <function_calls> opener. Unlike `preamble` (cumulative: the
|
|
77
|
+
* whole turn so far, including harness-injected <function_results> XML),
|
|
78
|
+
* this never repeats earlier rounds and never contains injected results.
|
|
79
|
+
* Consumers persisting per-round assistant text must prefer this field —
|
|
80
|
+
* persisting the cumulative `preamble` per round stores each round's text
|
|
81
|
+
* N times and re-persists injected results as model text (the Evander
|
|
82
|
+
* 2026-08-08 scaffold-leak pyramid).
|
|
83
|
+
*/
|
|
84
|
+
roundPreamble?: string;
|
|
85
|
+
|
|
66
86
|
/** Current depth in tool execution loop */
|
|
67
87
|
depth: number;
|
|
68
88
|
|
package/src/utils/tool-parser.ts
CHANGED
|
@@ -188,40 +188,94 @@ export function endsWithPartialToolBlock(text: string): boolean {
|
|
|
188
188
|
// ============================================================================
|
|
189
189
|
|
|
190
190
|
/**
|
|
191
|
-
*
|
|
192
|
-
*
|
|
191
|
+
* Structural tags of the XML tool convention. Result content containing any
|
|
192
|
+
* of these must be escaped or it would desync the document/stream parser;
|
|
193
|
+
* everything else rides raw (legacy convention — full escapeXml put `"`
|
|
194
|
+
* entities in front of the model, which Claude-3-era models then reproduce
|
|
195
|
+
* in their own output).
|
|
196
|
+
*/
|
|
197
|
+
const STRUCTURAL_TAG_RE =
|
|
198
|
+
/<\/?(?:antml:)?(?:function_calls|function_results|invoke|result|stdout|error|tool_name)\b/;
|
|
199
|
+
|
|
200
|
+
function renderResultContentString(result: ToolResult): string {
|
|
201
|
+
if (typeof result.content === 'string') {
|
|
202
|
+
return result.content;
|
|
203
|
+
}
|
|
204
|
+
const parts: string[] = [];
|
|
205
|
+
for (const block of result.content) {
|
|
206
|
+
if (block.type === 'text') {
|
|
207
|
+
parts.push(block.text);
|
|
208
|
+
} else if (block.type === 'image') {
|
|
209
|
+
// For XML mode, we can't embed images directly
|
|
210
|
+
// Add a note about the image for the model
|
|
211
|
+
const sizeKb = Math.round((block.source.data.length * 0.75) / 1024);
|
|
212
|
+
parts.push(`[Image: ${block.source.mediaType}, ~${sizeKb}KB]`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return parts.join('\n');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Format tool results as XML for injection — the LEGACY Anthropic tool
|
|
220
|
+
* convention Claude-3-era models were trained on:
|
|
221
|
+
*
|
|
222
|
+
* <function_results>
|
|
223
|
+
* <result>
|
|
224
|
+
* <tool_name>NAME</tool_name>
|
|
225
|
+
* <stdout>
|
|
226
|
+
* content
|
|
227
|
+
* </stdout>
|
|
228
|
+
* </result>
|
|
229
|
+
* </function_results>
|
|
230
|
+
*
|
|
231
|
+
* Errors render as <error>…</error> inside <function_results>. No
|
|
232
|
+
* tool_use_id attributes on the wire (a Messages-API concept — the
|
|
233
|
+
* store keeps the linkage on the blocks); content rides raw unless it
|
|
234
|
+
* contains structural tags (then escaped, see STRUCTURAL_TAG_RE).
|
|
193
235
|
*/
|
|
194
236
|
export function formatToolResults(results: ToolResult[]): string {
|
|
195
237
|
const parts: string[] = ['<function_results>'];
|
|
196
238
|
|
|
197
239
|
for (const result of results) {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
parts.push(
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
for (const block of result.content) {
|
|
207
|
-
if (block.type === 'text') {
|
|
208
|
-
parts.push(escapeXml(block.text));
|
|
209
|
-
} else if (block.type === 'image') {
|
|
210
|
-
// For XML mode, we can't embed images directly
|
|
211
|
-
// Add a note about the image for the model
|
|
212
|
-
const sizeKb = Math.round((block.source.data.length * 0.75) / 1024);
|
|
213
|
-
parts.push(`[Image: ${block.source.mediaType}, ~${sizeKb}KB]`);
|
|
214
|
-
}
|
|
240
|
+
if (result.isError) {
|
|
241
|
+
parts.push('<error>');
|
|
242
|
+
parts.push(guardResultContent(renderResultContentString(result)));
|
|
243
|
+
parts.push('</error>');
|
|
244
|
+
} else {
|
|
245
|
+
parts.push('<result>');
|
|
246
|
+
if (result.toolName) {
|
|
247
|
+
parts.push(`<tool_name>${result.toolName}</tool_name>`);
|
|
215
248
|
}
|
|
249
|
+
parts.push('<stdout>');
|
|
250
|
+
parts.push(guardResultContent(renderResultContentString(result)));
|
|
251
|
+
parts.push('</stdout>');
|
|
252
|
+
parts.push('</result>');
|
|
216
253
|
}
|
|
217
|
-
|
|
218
|
-
parts.push(`</${tagName}>`);
|
|
219
254
|
}
|
|
220
255
|
|
|
221
256
|
parts.push('</function_results>');
|
|
222
257
|
return parts.join('\n');
|
|
223
258
|
}
|
|
224
259
|
|
|
260
|
+
/** Escape result content only when it would desync the structural parse. */
|
|
261
|
+
function guardResultContent(s: string): string {
|
|
262
|
+
return STRUCTURAL_TAG_RE.test(s) ? escapeXml(s) : s;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Opening XML of one result, up to where its content begins. */
|
|
266
|
+
function resultOpenXml(result: ToolResult): string {
|
|
267
|
+
if (result.isError) return '<error>\n';
|
|
268
|
+
let xml = '<result>\n';
|
|
269
|
+
if (result.toolName) xml += `<tool_name>${result.toolName}</tool_name>\n`;
|
|
270
|
+
xml += '<stdout>\n';
|
|
271
|
+
return xml;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Closing XML of one result, after its content. */
|
|
275
|
+
function resultCloseXml(result: ToolResult): string {
|
|
276
|
+
return result.isError ? '\n</error>\n' : '\n</stdout>\n</result>\n';
|
|
277
|
+
}
|
|
278
|
+
|
|
225
279
|
/**
|
|
226
280
|
* Format a single tool result
|
|
227
281
|
*/
|
|
@@ -292,6 +346,13 @@ const FUNCTION_RESULTS_BLOCK_REGEX = /<(antml:)?function_results>([\s\S]*?)<\/(a
|
|
|
292
346
|
const RESULT_REGEX = /<result\s+tool_use_id="([^"]+)">([\s\S]*?)<\/result>/g;
|
|
293
347
|
const ERROR_REGEX = /<error\s+tool_use_id="([^"]+)">([\s\S]*?)<\/error>/g;
|
|
294
348
|
|
|
349
|
+
// Legacy Anthropic convention — no ids on the wire; results pair
|
|
350
|
+
// positionally with the preceding unmatched tool calls in document order
|
|
351
|
+
// (optionally disambiguated by <tool_name>).
|
|
352
|
+
const LEGACY_RESULT_REGEX =
|
|
353
|
+
/<result>\s*(?:<tool_name>([\s\S]*?)<\/tool_name>\s*)?<stdout>\n?([\s\S]*?)\n?<\/stdout>\s*<\/result>/g;
|
|
354
|
+
const LEGACY_ERROR_REGEX = /<error>\n?([\s\S]*?)\n?<\/error>/g;
|
|
355
|
+
|
|
295
356
|
/**
|
|
296
357
|
* Parse accumulated assistant text into structured ContentBlock[].
|
|
297
358
|
* Extracts thinking blocks, tool calls, tool results, and plain text.
|
|
@@ -331,6 +392,21 @@ export function parseAccumulatedIntoBlocks(
|
|
|
331
392
|
};
|
|
332
393
|
const positions: BlockPosition[] = [];
|
|
333
394
|
|
|
395
|
+
// Call sites in document order, for pairing legacy-shaped results
|
|
396
|
+
// (no tool_use_id on the wire) with the calls they answer.
|
|
397
|
+
const callSites: Array<{ id: string; name: string; pos: number }> = [];
|
|
398
|
+
const pairedCallIds = new Set<string>();
|
|
399
|
+
const claimCall = (beforePos: number, name?: string): string => {
|
|
400
|
+
for (const site of callSites) {
|
|
401
|
+
if (site.pos >= beforePos) break;
|
|
402
|
+
if (pairedCallIds.has(site.id)) continue;
|
|
403
|
+
if (name && site.name !== name) continue;
|
|
404
|
+
pairedCallIds.add(site.id);
|
|
405
|
+
return site.id;
|
|
406
|
+
}
|
|
407
|
+
return generateToolId();
|
|
408
|
+
};
|
|
409
|
+
|
|
334
410
|
// Find all thinking blocks
|
|
335
411
|
THINKING_BLOCK_REGEX.lastIndex = 0;
|
|
336
412
|
let thinkingMatch: RegExpExecArray | null;
|
|
@@ -377,6 +453,7 @@ export function parseAccumulatedIntoBlocks(
|
|
|
377
453
|
const id = generateToolId();
|
|
378
454
|
const toolCall: ToolCall = { id, name: toolName, input };
|
|
379
455
|
toolCalls.push(toolCall);
|
|
456
|
+
callSites.push({ id, name: toolName, pos: funcMatch.index });
|
|
380
457
|
blockToolCalls.push({
|
|
381
458
|
type: 'tool_use',
|
|
382
459
|
id,
|
|
@@ -394,6 +471,7 @@ export function parseAccumulatedIntoBlocks(
|
|
|
394
471
|
const id = generateToolId();
|
|
395
472
|
const toolCall: ToolCall = { id, name: toolName, input: {} };
|
|
396
473
|
toolCalls.push(toolCall);
|
|
474
|
+
callSites.push({ id, name: toolName, pos: funcMatch.index });
|
|
397
475
|
blockToolCalls.push({
|
|
398
476
|
type: 'tool_use',
|
|
399
477
|
id,
|
|
@@ -428,6 +506,7 @@ export function parseAccumulatedIntoBlocks(
|
|
|
428
506
|
while ((resultMatch = RESULT_REGEX.exec(innerContent)) !== null) {
|
|
429
507
|
const toolUseId = resultMatch[1] ?? '';
|
|
430
508
|
const content = unescapeXml(resultMatch[2] ?? '');
|
|
509
|
+
pairedCallIds.add(toolUseId);
|
|
431
510
|
const result: ToolResult = { toolUseId, content, isError: false };
|
|
432
511
|
toolResults.push(result);
|
|
433
512
|
blockResults.push({
|
|
@@ -445,6 +524,7 @@ export function parseAccumulatedIntoBlocks(
|
|
|
445
524
|
while ((errorMatch = ERROR_REGEX.exec(innerContent)) !== null) {
|
|
446
525
|
const toolUseId = errorMatch[1] ?? '';
|
|
447
526
|
const content = unescapeXml(errorMatch[2] ?? '');
|
|
527
|
+
pairedCallIds.add(toolUseId);
|
|
448
528
|
const result: ToolResult = { toolUseId, content, isError: true };
|
|
449
529
|
toolResults.push(result);
|
|
450
530
|
blockResults.push({
|
|
@@ -456,6 +536,39 @@ export function parseAccumulatedIntoBlocks(
|
|
|
456
536
|
});
|
|
457
537
|
}
|
|
458
538
|
|
|
539
|
+
// Legacy-shaped results/errors (no ids on the wire): pair positionally
|
|
540
|
+
// with the preceding unclaimed calls, disambiguated by <tool_name>.
|
|
541
|
+
LEGACY_RESULT_REGEX.lastIndex = 0;
|
|
542
|
+
let legacyResultMatch: RegExpExecArray | null;
|
|
543
|
+
while ((legacyResultMatch = LEGACY_RESULT_REGEX.exec(innerContent)) !== null) {
|
|
544
|
+
const toolName = legacyResultMatch[1]?.trim() || undefined;
|
|
545
|
+
const content = unescapeXml(legacyResultMatch[2] ?? '');
|
|
546
|
+
const toolUseId = claimCall(resultsMatch.index, toolName);
|
|
547
|
+
toolResults.push({ toolUseId, toolName, content, isError: false });
|
|
548
|
+
blockResults.push({
|
|
549
|
+
type: 'tool_result',
|
|
550
|
+
toolUseId,
|
|
551
|
+
toolName,
|
|
552
|
+
content,
|
|
553
|
+
isError: false,
|
|
554
|
+
rawXml,
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
LEGACY_ERROR_REGEX.lastIndex = 0;
|
|
558
|
+
let legacyErrorMatch: RegExpExecArray | null;
|
|
559
|
+
while ((legacyErrorMatch = LEGACY_ERROR_REGEX.exec(innerContent)) !== null) {
|
|
560
|
+
const content = unescapeXml(legacyErrorMatch[1] ?? '');
|
|
561
|
+
const toolUseId = claimCall(resultsMatch.index);
|
|
562
|
+
toolResults.push({ toolUseId, content, isError: true });
|
|
563
|
+
blockResults.push({
|
|
564
|
+
type: 'tool_result',
|
|
565
|
+
toolUseId,
|
|
566
|
+
content,
|
|
567
|
+
isError: true,
|
|
568
|
+
rawXml,
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
|
|
459
572
|
if (blockResults.length > 0) {
|
|
460
573
|
positions.push({
|
|
461
574
|
start: resultsMatch.index,
|
|
@@ -628,7 +741,6 @@ export function formatToolResultsForSplitTurn(results: ToolResult[]): SplitTurnC
|
|
|
628
741
|
|
|
629
742
|
for (let i = 0; i < results.length; i++) {
|
|
630
743
|
const result = results[i]!;
|
|
631
|
-
const tagName = result.isError ? 'error' : 'result';
|
|
632
744
|
|
|
633
745
|
// Check if this result has images
|
|
634
746
|
let resultHasImages = false;
|
|
@@ -636,14 +748,14 @@ export function formatToolResultsForSplitTurn(results: ToolResult[]): SplitTurnC
|
|
|
636
748
|
let resultImages: ProviderImageBlock[] = [];
|
|
637
749
|
|
|
638
750
|
if (typeof result.content === 'string') {
|
|
639
|
-
textParts.push(
|
|
751
|
+
textParts.push(guardResultContent(result.content));
|
|
640
752
|
} else if (Array.isArray(result.content)) {
|
|
641
753
|
for (const block of result.content) {
|
|
642
754
|
if (block.type === 'text') {
|
|
643
|
-
textParts.push(
|
|
755
|
+
textParts.push(guardResultContent(block.text));
|
|
644
756
|
} else if (block.type === 'image') {
|
|
645
757
|
if (!isAcceptedImageMediaType(block.source.mediaType)) {
|
|
646
|
-
textParts.push(
|
|
758
|
+
textParts.push(strippedImagePlaceholder(block.source.mediaType).text);
|
|
647
759
|
} else {
|
|
648
760
|
resultHasImages = true;
|
|
649
761
|
resultImages.push({
|
|
@@ -664,15 +776,15 @@ export function formatToolResultsForSplitTurn(results: ToolResult[]): SplitTurnC
|
|
|
664
776
|
imageInsertionPoint = i;
|
|
665
777
|
images.push(...resultImages);
|
|
666
778
|
|
|
667
|
-
// Add opening
|
|
668
|
-
beforeImageXml +=
|
|
779
|
+
// Add opening tags and text content (no closing tags yet)
|
|
780
|
+
beforeImageXml += resultOpenXml(result);
|
|
669
781
|
if (textParts.length > 0) {
|
|
670
782
|
beforeImageXml += textParts.join('\n');
|
|
671
783
|
}
|
|
672
|
-
// Note: Intentionally NOT adding closing
|
|
784
|
+
// Note: Intentionally NOT adding closing tags - split happens here
|
|
673
785
|
|
|
674
786
|
// After image, we need to close this result and add remaining results
|
|
675
|
-
afterImageXml =
|
|
787
|
+
afterImageXml = resultCloseXml(result);
|
|
676
788
|
|
|
677
789
|
// Process remaining results into afterImageXml
|
|
678
790
|
for (let j = i + 1; j < results.length; j++) {
|
|
@@ -685,9 +797,9 @@ export function formatToolResultsForSplitTurn(results: ToolResult[]): SplitTurnC
|
|
|
685
797
|
break;
|
|
686
798
|
} else if (imageInsertionPoint === -1) {
|
|
687
799
|
// No images yet - add full result to beforeImageXml
|
|
688
|
-
beforeImageXml +=
|
|
800
|
+
beforeImageXml += resultOpenXml(result);
|
|
689
801
|
beforeImageXml += textParts.join('\n');
|
|
690
|
-
beforeImageXml +=
|
|
802
|
+
beforeImageXml += resultCloseXml(result);
|
|
691
803
|
}
|
|
692
804
|
}
|
|
693
805
|
|
|
@@ -714,15 +826,14 @@ export function formatToolResultsForSplitTurn(results: ToolResult[]): SplitTurnC
|
|
|
714
826
|
* Format a single tool result as complete XML
|
|
715
827
|
*/
|
|
716
828
|
function formatSingleResultXml(result: ToolResult): string {
|
|
717
|
-
|
|
718
|
-
let xml = `<${tagName} tool_use_id="${result.toolUseId}">\n`;
|
|
829
|
+
let xml = resultOpenXml(result);
|
|
719
830
|
|
|
720
831
|
if (typeof result.content === 'string') {
|
|
721
|
-
xml +=
|
|
832
|
+
xml += guardResultContent(result.content);
|
|
722
833
|
} else if (Array.isArray(result.content)) {
|
|
723
834
|
for (const block of result.content) {
|
|
724
835
|
if (block.type === 'text') {
|
|
725
|
-
xml +=
|
|
836
|
+
xml += guardResultContent(block.text);
|
|
726
837
|
} else if (block.type === 'image') {
|
|
727
838
|
// For remaining results after split, images become text placeholders
|
|
728
839
|
const sizeKb = Math.round((block.source.data.length * 0.75) / 1024);
|
|
@@ -731,7 +842,7 @@ function formatSingleResultXml(result: ToolResult): string {
|
|
|
731
842
|
}
|
|
732
843
|
}
|
|
733
844
|
|
|
734
|
-
xml +=
|
|
845
|
+
xml += resultCloseXml(result);
|
|
735
846
|
return xml;
|
|
736
847
|
}
|
|
737
848
|
|