@animalabs/membrane 0.5.52 → 0.5.54

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.
@@ -0,0 +1,663 @@
1
+ /**
2
+ * Tool-Pair Normalizer
3
+ *
4
+ * Anthropic's API enforces structural rules on tool cycles that any of
5
+ * Membrane's upstreams can accidentally violate:
6
+ *
7
+ * - `tool_use` blocks must live in assistant-role messages.
8
+ * - `tool_result` blocks must live in user-role messages.
9
+ * - Every `tool_use` must be matched by its `tool_result` in the very
10
+ * next user-role message.
11
+ * - `thinking` blocks must live in assistant turns.
12
+ *
13
+ * When these are violated, the API returns 400 (e.g. `tool_use blocks can
14
+ * only be in assistant messages`). This module is the wire-boundary safety
15
+ * net: every formatter funnels through `normalizeToolPairs` before its
16
+ * output is shipped, so producer-side bugs cannot leak the same 400 family
17
+ * (compression-bug 5/6/7/8/9, agent-framework #37, 2026-05-22 miner stall).
18
+ *
19
+ * Algorithm overview (six phases): reclassify blocks by required role,
20
+ * reflow into role-correct envelopes, hoist matching tool_results across
21
+ * the assistant→user boundary, evict interlopers wedged between use and
22
+ * result, synthesize `[pending]` results for trailing orphans (or signal
23
+ * not-ready when the id is in the caller-supplied pending set), validate.
24
+ */
25
+
26
+ import type { ProviderMessage as LooseProviderMessage } from './types.js';
27
+ import type { NormalizeEvent } from './types.js';
28
+
29
+ // ============================================================================
30
+ // Public API
31
+ // ============================================================================
32
+
33
+ /**
34
+ * Block shape used internally and exposed for callers that want to
35
+ * build inputs without the full Anthropic SDK types. The required
36
+ * `type` discriminator names the kind of block; any block whose `type`
37
+ * matches a strict-role entry in `requiredRoleOf` is re-roled to its
38
+ * required role during normalization. Unrecognized `tool_*` or
39
+ * `thinking*` types fall through as `inherit` — see the one-shot
40
+ * warning below.
41
+ */
42
+ export type ProviderBlock = Record<string, unknown> & { type: string };
43
+
44
+ export interface NormalizeOptions {
45
+ /** See `BuildOptions.pendingToolCallIds`. */
46
+ pendingToolCallIds?: ReadonlySet<string>;
47
+ /** See `BuildOptions.onNormalize`. */
48
+ onEvent?: (event: NormalizeEvent) => void;
49
+ }
50
+
51
+ export interface NormalizeResult {
52
+ /**
53
+ * Normalized messages, structurally compatible with the loose
54
+ * `ProviderMessage` from `./types.js`. Block contents are
55
+ * `ProviderBlock[]` at runtime; the loose type is preserved at the
56
+ * public boundary so callers wired against `./types.js` don't need
57
+ * to cast.
58
+ */
59
+ messages: LooseProviderMessage[];
60
+ /**
61
+ * `false` iff a trailing unmatched tool_use's id was in
62
+ * `pendingToolCallIds`. Caller should wait for the in-flight result
63
+ * to land and retry instead of shipping the request.
64
+ */
65
+ ready: boolean;
66
+ }
67
+
68
+ export class MembraneNormalizerError extends Error {
69
+ constructor(
70
+ message: string,
71
+ public readonly input: ReadonlyArray<LooseProviderMessage>,
72
+ public readonly output: ReadonlyArray<LooseProviderMessage>,
73
+ ) {
74
+ super(message);
75
+ this.name = 'MembraneNormalizerError';
76
+ }
77
+ }
78
+
79
+ // ============================================================================
80
+ // Implementation
81
+ // ============================================================================
82
+
83
+ /**
84
+ * Normalize a sequence of provider messages so the output is API-valid
85
+ * with respect to Anthropic's tool-cycle structural rules.
86
+ *
87
+ * This function does NOT merge consecutive same-role envelopes — that
88
+ * is the responsibility of `mergeConsecutiveRoles` (exported below). Run
89
+ * the two together at every wire boundary; splitting them keeps the
90
+ * normalize step independently testable and lets callers preserve their
91
+ * own cache-control / breakpoint logic between the two steps if needed.
92
+ */
93
+ export function normalizeToolPairs(
94
+ input: ReadonlyArray<LooseProviderMessage>,
95
+ options: NormalizeOptions = {},
96
+ ): NormalizeResult {
97
+ const pending = options.pendingToolCallIds ?? new Set<string>();
98
+ const onEvent = options.onEvent ?? noop;
99
+
100
+ // ---------------------------------------------------------------------
101
+ // Phase 1 + 2: reclassify blocks by required role and reflow envelopes
102
+ // ---------------------------------------------------------------------
103
+ let envelopes = rebuildEnvelopes(input, onEvent);
104
+
105
+ // ---------------------------------------------------------------------
106
+ // Phase 3: pair tool_use → tool_result across assistant→user boundary
107
+ // ---------------------------------------------------------------------
108
+ envelopes = hoistMatchingResults(envelopes, onEvent);
109
+
110
+ // ---------------------------------------------------------------------
111
+ // Phase 4: evict interlopers wedged between a tool_use and its result
112
+ // ---------------------------------------------------------------------
113
+ envelopes = evictInterlopers(envelopes, onEvent);
114
+
115
+ // ---------------------------------------------------------------------
116
+ // Phase 5: resolve orphans
117
+ // ---------------------------------------------------------------------
118
+ const orphanRes = resolveOrphans(envelopes, pending, onEvent);
119
+ envelopes = orphanRes.envelopes;
120
+ const ready = orphanRes.ready;
121
+
122
+ // ---------------------------------------------------------------------
123
+ // Phase 5.5: suppress cache_control on/after any envelope containing
124
+ // a synthetic block, so cache keys don't get invalidated when the
125
+ // real result arrives in a later round.
126
+ // ---------------------------------------------------------------------
127
+ if (orphanRes.firstSyntheticEnvelope !== null) {
128
+ suppressCacheControlFrom(envelopes, orphanRes.firstSyntheticEnvelope, onEvent);
129
+ }
130
+
131
+ // ---------------------------------------------------------------------
132
+ // Phase 6: drop empty envelopes (can arise from phase 4 dropping or
133
+ // phase 3 hoisting), repair first-message-must-be-user, validate. We
134
+ // deliberately do NOT merge consecutive same-role envelopes here —
135
+ // that's the formatter's job.
136
+ // ---------------------------------------------------------------------
137
+ envelopes = envelopes.filter((e) => e.content.length > 0);
138
+
139
+ // First-message-must-be-user repair: only repair the case where the
140
+ // original input's first message WAS user, but re-roling moved blocks
141
+ // to a leading assistant envelope (e.g. misplaced thinking block).
142
+ // If the producer genuinely shipped an assistant-first conversation,
143
+ // that's a real bug and validate() will throw.
144
+ const originalFirstRole = input.length > 0 ? input[0]!.role : 'user';
145
+ if (
146
+ envelopes.length > 0 &&
147
+ envelopes[0]!.role === 'assistant' &&
148
+ originalFirstRole === 'user'
149
+ ) {
150
+ envelopes.unshift({ role: 'user', content: [{ type: 'text', text: '[continuing]' }] });
151
+ }
152
+
153
+ // Validate. When `ready === false` we intentionally have unmatched
154
+ // tool_uses — but ONLY the ones in `pending` are allowed to remain
155
+ // unsynthesized. Any other gap is a bug in phase 5 and must throw.
156
+ validate(envelopes, input, pending);
157
+
158
+ return { messages: envelopes.map(toProviderMessage), ready };
159
+ }
160
+
161
+ // ============================================================================
162
+ // Phase implementations
163
+ // ============================================================================
164
+
165
+ interface Envelope {
166
+ role: 'user' | 'assistant';
167
+ content: ProviderBlock[];
168
+ }
169
+
170
+ type RequiredRole = 'user' | 'assistant' | 'inherit';
171
+
172
+ /**
173
+ * Role-strict block types. Extending Anthropic's tool surface
174
+ * (e.g. `server_tool_use`, `web_search_tool_result`, `computer_use`)
175
+ * means adding entries here. Unknown block types whose `type` starts
176
+ * with `tool_` or `thinking` fall through to 'inherit' and trigger a
177
+ * one-shot console warning so the next addition doesn't sail silently
178
+ * through the safety net.
179
+ */
180
+ function requiredRoleOf(block: ProviderBlock): RequiredRole {
181
+ switch (block.type) {
182
+ case 'tool_use':
183
+ case 'thinking':
184
+ case 'redacted_thinking':
185
+ return 'assistant';
186
+ case 'tool_result':
187
+ return 'user';
188
+ default:
189
+ if (block.type.startsWith('tool_') || block.type.startsWith('thinking')) {
190
+ warnUnknownStrictType(block.type);
191
+ }
192
+ return 'inherit';
193
+ }
194
+ }
195
+
196
+ const _warnedTypes = new Set<string>();
197
+ function warnUnknownStrictType(blockType: string): void {
198
+ if (_warnedTypes.has(blockType)) return;
199
+ _warnedTypes.add(blockType);
200
+ // eslint-disable-next-line no-console
201
+ console.warn(
202
+ `[membrane:normalize-tool-pairs] Unknown strict-role block type '${blockType}' — ` +
203
+ `falling through as 'inherit'. If this type has role placement rules at the API, ` +
204
+ `add it to requiredRoleOf in normalize-tool-pairs.ts.`,
205
+ );
206
+ }
207
+
208
+ function rebuildEnvelopes(
209
+ input: ReadonlyArray<LooseProviderMessage>,
210
+ onEvent: (e: NormalizeEvent) => void,
211
+ ): Envelope[] {
212
+ const out: Envelope[] = [];
213
+ let current: Envelope | null = null;
214
+
215
+ for (const msg of input) {
216
+ if (!Array.isArray(msg.content)) {
217
+ // Defensive: provider message with non-array content (e.g. a plain
218
+ // string). Treat it as a single text block under the message's
219
+ // declared role.
220
+ const role = msg.role;
221
+ if (current === null || current.role !== role) {
222
+ if (current) out.push(current);
223
+ current = { role, content: [] };
224
+ }
225
+ current.content.push({ type: 'text', text: String(msg.content ?? '') });
226
+ continue;
227
+ }
228
+
229
+ for (const block of msg.content as ProviderBlock[]) {
230
+ const req = requiredRoleOf(block);
231
+ const targetRole: 'user' | 'assistant' = req === 'inherit' ? msg.role : req;
232
+
233
+ if (req !== 'inherit' && req !== msg.role) {
234
+ onEvent({
235
+ kind: 'block_re_roled',
236
+ blockType: block.type,
237
+ from: msg.role,
238
+ to: req,
239
+ });
240
+ }
241
+
242
+ if (current === null || current.role !== targetRole) {
243
+ if (current) out.push(current);
244
+ current = { role: targetRole, content: [] };
245
+ }
246
+ current.content.push(block);
247
+ }
248
+ }
249
+
250
+ if (current) out.push(current);
251
+ return out;
252
+ }
253
+
254
+ function hoistMatchingResults(
255
+ envelopes: Envelope[],
256
+ onEvent: (e: NormalizeEvent) => void,
257
+ ): Envelope[] {
258
+ // For every assistant envelope, ensure its tool_use ids have matching
259
+ // tool_results in the immediately-following user envelope. If a
260
+ // matching tool_result lives further downstream, hoist it forward.
261
+ for (let i = 0; i < envelopes.length; i++) {
262
+ const env = envelopes[i]!;
263
+ if (env.role !== 'assistant') continue;
264
+ const useIds = collectToolUseIds(env);
265
+ if (useIds.length === 0) continue;
266
+
267
+ // Ensure there is a user envelope at i+1. If not, insert an empty one.
268
+ let nextIdx = i + 1;
269
+ if (nextIdx >= envelopes.length || envelopes[nextIdx]!.role !== 'user') {
270
+ envelopes.splice(nextIdx, 0, { role: 'user', content: [] });
271
+ }
272
+ const nextEnv = envelopes[nextIdx]!;
273
+ const presentIds = new Set(
274
+ nextEnv.content
275
+ .filter((b) => b.type === 'tool_result')
276
+ .map(getToolUseId)
277
+ .filter((id): id is string => typeof id === 'string'),
278
+ );
279
+
280
+ for (const useId of useIds) {
281
+ if (presentIds.has(useId)) continue;
282
+
283
+ // Search downstream envelopes for this id; hoist the first match.
284
+ const found = removeFirstMatchingResult(envelopes, nextIdx + 1, useId);
285
+ if (found) {
286
+ // Place the hoisted result at the front of nextEnv to keep
287
+ // tool_results adjacent to (and before) any interloping content
288
+ // already present.
289
+ nextEnv.content.unshift(found.block);
290
+ presentIds.add(useId);
291
+ onEvent({
292
+ kind: 'tool_result_hoisted',
293
+ toolUseId: useId,
294
+ fromEnvelope: found.fromEnvelope,
295
+ toEnvelope: nextIdx,
296
+ });
297
+ }
298
+ // If not found downstream, leave it — phase 5 will synthesize.
299
+ }
300
+ }
301
+ return envelopes;
302
+ }
303
+
304
+ function evictInterlopers(
305
+ envelopes: Envelope[],
306
+ onEvent: (e: NormalizeEvent) => void,
307
+ ): Envelope[] {
308
+ // For every assistant envelope ending with a tool_use, the
309
+ // immediately-following user envelope's tool_results should appear
310
+ // BEFORE any interloping text/image/etc. — otherwise the agent's
311
+ // forward timeline reads "tool called, then [unrelated event], then
312
+ // tool result." Phase 3 already places hoisted results at the front,
313
+ // but locally-present results may sit after text in the same envelope
314
+ // (e.g. user sent a chat message and the tool_result is appended
315
+ // afterward by the producer). We always defer interlopers — never
316
+ // drop — so that a mid-cycle user event isn't lost to the agent's
317
+ // long-term memory after the chunk gets summarized. A summarizer LLM
318
+ // can tolerate slight temporal reordering; it cannot reconstruct a
319
+ // message that was discarded.
320
+ for (let i = 0; i < envelopes.length; i++) {
321
+ const env = envelopes[i]!;
322
+ if (env.role !== 'assistant') continue;
323
+ const useIds = new Set(collectToolUseIds(env));
324
+ if (useIds.size === 0) continue;
325
+ const next = envelopes[i + 1];
326
+ if (!next || next.role !== 'user') continue;
327
+
328
+ const matching: ProviderBlock[] = [];
329
+ const interlopers: ProviderBlock[] = [];
330
+ const rest: ProviderBlock[] = [];
331
+
332
+ let seenMatching = false;
333
+ for (const block of next.content) {
334
+ const isResult = block.type === 'tool_result';
335
+ const resultId = isResult ? getToolUseId(block) : undefined;
336
+ const isMatching = isResult && typeof resultId === 'string' && useIds.has(resultId);
337
+
338
+ if (isMatching) {
339
+ matching.push(block);
340
+ seenMatching = true;
341
+ } else if (!seenMatching && !isResult) {
342
+ // Block precedes the first matching tool_result. Treat as
343
+ // interloper only if it would sit between the assistant's
344
+ // tool_use and its result.
345
+ interlopers.push(block);
346
+ } else {
347
+ rest.push(block);
348
+ }
349
+ }
350
+
351
+ if (interlopers.length === 0) continue;
352
+
353
+ for (const block of interlopers) {
354
+ onEvent({
355
+ kind: 'interloper_deferred',
356
+ blockType: block.type,
357
+ fromEnvelope: i + 1,
358
+ });
359
+ }
360
+ next.content = [...matching, ...interlopers, ...rest];
361
+ }
362
+ return envelopes;
363
+ }
364
+
365
+ interface OrphanResolution {
366
+ envelopes: Envelope[];
367
+ ready: boolean;
368
+ firstSyntheticEnvelope: number | null;
369
+ }
370
+
371
+ function resolveOrphans(
372
+ envelopes: Envelope[],
373
+ pending: ReadonlySet<string>,
374
+ onEvent: (e: NormalizeEvent) => void,
375
+ ): OrphanResolution {
376
+ let ready = true;
377
+ let firstSyntheticEnvelope: number | null = null;
378
+
379
+ // First pass: textify any tool_result whose tool_use never appeared
380
+ // anywhere in the message list (orphan result).
381
+ const allUseIds = new Set<string>();
382
+ for (const env of envelopes) {
383
+ for (const block of env.content) {
384
+ if (block.type === 'tool_use') {
385
+ const id = (block as { id?: string }).id;
386
+ if (typeof id === 'string') allUseIds.add(id);
387
+ }
388
+ }
389
+ }
390
+ for (const env of envelopes) {
391
+ if (env.role !== 'user') continue;
392
+ env.content = env.content.map((block) => {
393
+ if (block.type !== 'tool_result') return block;
394
+ const id = getToolUseId(block);
395
+ if (typeof id !== 'string' || !allUseIds.has(id)) {
396
+ const inner = (block as { content?: unknown }).content;
397
+ const innerText = typeof inner === 'string' ? inner : '';
398
+ onEvent({ kind: 'orphan_tool_result_textified', toolUseId: id ?? '<missing>' });
399
+ return {
400
+ type: 'text',
401
+ text: `[orphan tool_result for ${id ?? '<missing>'}]: ${innerText}`,
402
+ };
403
+ }
404
+ return block;
405
+ });
406
+ }
407
+
408
+ // Second pass: for each assistant envelope, every tool_use must have
409
+ // a matching tool_result in the immediately-following user envelope.
410
+ // If pending, signal not-ready. Else, synthesize.
411
+ for (let i = 0; i < envelopes.length; i++) {
412
+ const env = envelopes[i]!;
413
+ if (env.role !== 'assistant') continue;
414
+ const useIds = collectToolUseIds(env);
415
+ if (useIds.length === 0) continue;
416
+
417
+ let nextIdx = i + 1;
418
+ if (nextIdx >= envelopes.length || envelopes[nextIdx]!.role !== 'user') {
419
+ envelopes.splice(nextIdx, 0, { role: 'user', content: [] });
420
+ }
421
+ const nextEnv = envelopes[nextIdx]!;
422
+ // 'trailing' iff after the next user envelope there are no further
423
+ // envelopes AND the next envelope is empty (so it exists only to
424
+ // receive our synthetic). This must be computed *after* the splice
425
+ // because phase 3 may have already inserted an empty user envelope
426
+ // earlier in the pipeline.
427
+ const isTrailing =
428
+ nextIdx + 1 >= envelopes.length && nextEnv.content.length === 0;
429
+ const presentIds = new Set(
430
+ nextEnv.content
431
+ .filter((b) => b.type === 'tool_result')
432
+ .map(getToolUseId)
433
+ .filter((id): id is string => typeof id === 'string'),
434
+ );
435
+
436
+ for (const useId of useIds) {
437
+ if (presentIds.has(useId)) continue;
438
+ if (pending.has(useId)) {
439
+ ready = false;
440
+ onEvent({ kind: 'pending_in_flight', toolUseId: useId });
441
+ continue;
442
+ }
443
+ const synth = syntheticToolResult(useId);
444
+ // Place at the front so it's adjacent to the tool_use.
445
+ nextEnv.content.unshift(synth);
446
+ presentIds.add(useId);
447
+ if (firstSyntheticEnvelope === null) firstSyntheticEnvelope = nextIdx;
448
+ onEvent({
449
+ kind: 'synthetic_pending_result',
450
+ toolUseId: useId,
451
+ reason: isTrailing ? 'trailing' : 'mid_stream',
452
+ });
453
+ }
454
+ }
455
+
456
+ return { envelopes, ready, firstSyntheticEnvelope };
457
+ }
458
+
459
+ function suppressCacheControlFrom(
460
+ envelopes: Envelope[],
461
+ startIndex: number,
462
+ onEvent: (e: NormalizeEvent) => void,
463
+ ): void {
464
+ // Strip cache_control from blocks at-or-after startIndex. We must NOT
465
+ // mutate the caller's input blocks (envelopes share references with
466
+ // the input via rebuildEnvelopes), so clone-on-write: replace any
467
+ // block carrying cache_control with a shallow copy that omits it.
468
+ // The envelope's content array is replaced wholesale via .map; this
469
+ // is the only place in the normalizer that creates new block objects
470
+ // out of existing ones (synthetics aside).
471
+ let suppressed = false;
472
+ for (let i = startIndex; i < envelopes.length; i++) {
473
+ const env = envelopes[i]!;
474
+ env.content = env.content.map((block) => {
475
+ if (!('cache_control' in block)) return block;
476
+ suppressed = true;
477
+ const { cache_control: _drop, ...rest } = block as ProviderBlock & {
478
+ cache_control?: unknown;
479
+ };
480
+ return rest as ProviderBlock;
481
+ });
482
+ }
483
+ if (suppressed) {
484
+ onEvent({ kind: 'cache_suppressed_for_synthetic', envelopeIndex: startIndex });
485
+ }
486
+ }
487
+
488
+ function validate(
489
+ envelopes: Envelope[],
490
+ input: ReadonlyArray<LooseProviderMessage>,
491
+ pending: ReadonlySet<string>,
492
+ ): void {
493
+ // Empty input → empty output is fine.
494
+ if (envelopes.length === 0) return;
495
+
496
+ // First message must be user (Anthropic requirement). We try to
497
+ // repair this in the caller; if it still isn't user here, fail.
498
+ if (envelopes[0]!.role !== 'user') {
499
+ throw new MembraneNormalizerError(
500
+ `First message must have role 'user', got '${envelopes[0]!.role}'. ` +
501
+ `Repair (prepending '[continuing]') did not engage — internal bug.`,
502
+ input.map(cloneMsg),
503
+ envelopes.map(toProviderMessage),
504
+ );
505
+ }
506
+
507
+ // Every tool_use in an assistant envelope must have a matching
508
+ // tool_result in the immediately-following user envelope — except
509
+ // tool_uses whose id is in `pending` (the in-flight set the caller
510
+ // declared off-limits for synthesis). A gap on any other id is a
511
+ // phase-5 bug and must throw.
512
+ for (let i = 0; i < envelopes.length; i++) {
513
+ const env = envelopes[i]!;
514
+ if (env.role !== 'assistant') continue;
515
+ const useIds = collectToolUseIds(env);
516
+ if (useIds.length === 0) continue;
517
+ const next = envelopes[i + 1];
518
+ const presentIds = new Set(
519
+ next?.role === 'user'
520
+ ? next.content
521
+ .filter((b) => b.type === 'tool_result')
522
+ .map(getToolUseId)
523
+ .filter((id): id is string => typeof id === 'string')
524
+ : [],
525
+ );
526
+ for (const useId of useIds) {
527
+ if (presentIds.has(useId)) continue;
528
+ if (pending.has(useId)) continue; // legitimately in-flight
529
+ throw new MembraneNormalizerError(
530
+ `tool_use id='${useId}' in envelope ${i} has no matching tool_result in envelope ${i + 1}, ` +
531
+ `and the id is not in pendingToolCallIds. This indicates a bug in the normalizer itself — ` +
532
+ `phase 5 should have synthesized a result for any non-pending unmatched id.`,
533
+ input.map(cloneMsg),
534
+ envelopes.map(toProviderMessage),
535
+ );
536
+ }
537
+ }
538
+ }
539
+
540
+ // ============================================================================
541
+ // Helpers
542
+ // ============================================================================
543
+
544
+ /**
545
+ * Read a tool_result's id, tolerating either Anthropic's canonical
546
+ * `tool_use_id` (snake_case) or the camelCase `toolUseId` some
547
+ * Membrane producers ship. Only used for *reading*; synthetic
548
+ * tool_results MUST be written in the canonical snake_case form
549
+ * (see {@link syntheticToolResult}) — the dual-form read is defensive
550
+ * against producers, not a license to mix.
551
+ */
552
+ function getToolUseId(block: ProviderBlock): string | undefined {
553
+ const b = block as { tool_use_id?: unknown; toolUseId?: unknown };
554
+ if (typeof b.tool_use_id === 'string') return b.tool_use_id;
555
+ if (typeof b.toolUseId === 'string') return b.toolUseId;
556
+ return undefined;
557
+ }
558
+
559
+ function collectToolUseIds(env: Envelope): string[] {
560
+ const ids: string[] = [];
561
+ for (const block of env.content) {
562
+ if (block.type === 'tool_use') {
563
+ const id = (block as { id?: string }).id;
564
+ if (typeof id === 'string') ids.push(id);
565
+ }
566
+ }
567
+ return ids;
568
+ }
569
+
570
+ function removeFirstMatchingResult(
571
+ envelopes: Envelope[],
572
+ fromIdx: number,
573
+ useId: string,
574
+ ): { block: ProviderBlock; fromEnvelope: number } | null {
575
+ for (let i = fromIdx; i < envelopes.length; i++) {
576
+ const env = envelopes[i]!;
577
+ if (env.role !== 'user') continue;
578
+ for (let j = 0; j < env.content.length; j++) {
579
+ const block = env.content[j]!;
580
+ if (block.type !== 'tool_result') continue;
581
+ if (getToolUseId(block) === useId) {
582
+ // Mutates the envelope's content array in place. Caller
583
+ // (phase 3) is expected to handle the possibly-empty source
584
+ // envelope; phase 6's filter sweeps any envelope left empty.
585
+ env.content.splice(j, 1);
586
+ return { block, fromEnvelope: i };
587
+ }
588
+ }
589
+ }
590
+ return null;
591
+ }
592
+
593
+ /**
594
+ * Synthetic tool_result for an unmatched tool_use. Writes
595
+ * `tool_use_id` in Anthropic's canonical snake_case form — do NOT
596
+ * change to camelCase without auditing every consumer of the
597
+ * downstream message. The "[pending]" content is intentionally
598
+ * tombstone-shaped (is_error: false) — most synthesis triggers are
599
+ * normal-flow gaps (cancellations, stream restarts), not failures
600
+ * worth alarming the agent about.
601
+ */
602
+ function syntheticToolResult(toolUseId: string): ProviderBlock {
603
+ return {
604
+ type: 'tool_result',
605
+ tool_use_id: toolUseId,
606
+ content: '[pending]',
607
+ is_error: false,
608
+ };
609
+ }
610
+
611
+ function toProviderMessage(env: Envelope): LooseProviderMessage {
612
+ return { role: env.role, content: env.content };
613
+ }
614
+
615
+ function cloneMsg(msg: LooseProviderMessage): LooseProviderMessage {
616
+ return {
617
+ role: msg.role,
618
+ content: Array.isArray(msg.content) ? [...msg.content] : msg.content,
619
+ };
620
+ }
621
+
622
+ function noop(): void {
623
+ /* intentionally empty */
624
+ }
625
+
626
+ /**
627
+ * Merge consecutive same-role envelopes by concatenating their content
628
+ * arrays. Anthropic's API requires strictly alternating user/assistant
629
+ * roles, and `normalizeToolPairs` can leave adjacent same-role envelopes
630
+ * (e.g. an assistant turn re-roled out of a user message, or two
631
+ * assistant turns stranded by an upstream chunker that dropped the
632
+ * tool_result message between them).
633
+ *
634
+ * This is the second half of the wire-boundary safety net and should run
635
+ * AFTER `normalizeToolPairs` at every callsite that ships messages to
636
+ * Anthropic. Hoisted here so both `NativeFormatter.buildMessages` and
637
+ * `Membrane.buildNativeToolRequest` share one implementation.
638
+ */
639
+ export function mergeConsecutiveRoles(
640
+ messages: ReadonlyArray<LooseProviderMessage>,
641
+ ): LooseProviderMessage[] {
642
+ if (messages.length === 0) return [];
643
+
644
+ const merged: LooseProviderMessage[] = [];
645
+ let current: LooseProviderMessage = { ...messages[0]! };
646
+
647
+ for (let i = 1; i < messages.length; i++) {
648
+ const next = messages[i]!;
649
+ if (next.role === current.role) {
650
+ const currentContent = Array.isArray(current.content) ? current.content : [current.content];
651
+ const nextContent = Array.isArray(next.content) ? next.content : [next.content];
652
+ current = {
653
+ role: current.role,
654
+ content: [...currentContent, ...nextContent],
655
+ };
656
+ } else {
657
+ merged.push(current);
658
+ current = { ...next };
659
+ }
660
+ }
661
+ merged.push(current);
662
+ return merged;
663
+ }