@animalabs/membrane 0.5.51 → 0.5.53

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