@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.
- package/dist/formatters/index.d.ts +2 -0
- package/dist/formatters/index.d.ts.map +1 -1
- package/dist/formatters/index.js +1 -0
- package/dist/formatters/index.js.map +1 -1
- package/dist/formatters/native.d.ts +0 -1
- package/dist/formatters/native.d.ts.map +1 -1
- package/dist/formatters/native.js +11 -25
- package/dist/formatters/native.js.map +1 -1
- package/dist/formatters/normalize-tool-pairs.d.ts +91 -0
- package/dist/formatters/normalize-tool-pairs.d.ts.map +1 -0
- package/dist/formatters/normalize-tool-pairs.js +536 -0
- package/dist/formatters/normalize-tool-pairs.js.map +1 -0
- package/dist/formatters/types.d.ts +55 -0
- package/dist/formatters/types.d.ts.map +1 -1
- package/dist/membrane.d.ts.map +1 -1
- package/dist/membrane.js +23 -1
- package/dist/membrane.js.map +1 -1
- package/package.json +1 -1
- package/src/formatters/index.ts +9 -0
- package/src/formatters/native.ts +12 -28
- package/src/formatters/normalize-tool-pairs.ts +663 -0
- package/src/formatters/types.ts +39 -0
- package/src/membrane.ts +25 -2
|
@@ -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
|
+
}
|