@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@animalabs/membrane",
3
- "version": "0.5.78",
3
+ "version": "0.5.79",
4
4
  "description": "LLM middleware - a selective boundary that transforms what passes through",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
- currentConversation.push(`${message.participant}: ${text}${this.config.messageDelimiter}`);
308
- if (!hasToolResult) {
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";
@@ -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
  }),
@@ -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
  /**
@@ -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
 
@@ -188,40 +188,94 @@ export function endsWithPartialToolBlock(text: string): boolean {
188
188
  // ============================================================================
189
189
 
190
190
  /**
191
- * Format tool results as XML for injection.
192
- * Handles both string content and structured content blocks (with images).
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 `&quot;`
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
- const tagName = result.isError ? 'error' : 'result';
199
- parts.push(`<${tagName} tool_use_id="${result.toolUseId}">`);
200
-
201
- // Handle both string and array content
202
- if (typeof result.content === 'string') {
203
- parts.push(escapeXml(result.content));
204
- } else if (Array.isArray(result.content)) {
205
- // Structured content blocks
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(escapeXml(result.content));
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(escapeXml(block.text));
755
+ textParts.push(guardResultContent(block.text));
644
756
  } else if (block.type === 'image') {
645
757
  if (!isAcceptedImageMediaType(block.source.mediaType)) {
646
- textParts.push(escapeXml(strippedImagePlaceholder(block.source.mediaType).text));
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 tag and text content (no closing tag yet)
668
- beforeImageXml += `<${tagName} tool_use_id="${result.toolUseId}">\n`;
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 tag - split happens here
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 = `</${tagName}>\n`;
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 += `<${tagName} tool_use_id="${result.toolUseId}">\n`;
800
+ beforeImageXml += resultOpenXml(result);
689
801
  beforeImageXml += textParts.join('\n');
690
- beforeImageXml += `\n</${tagName}>\n`;
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
- const tagName = result.isError ? 'error' : 'result';
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 += escapeXml(result.content);
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 += escapeXml(block.text);
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 += `\n</${tagName}>\n`;
845
+ xml += resultCloseXml(result);
735
846
  return xml;
736
847
  }
737
848