@animalabs/membrane 0.5.77 → 0.5.79
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/formatters/anthropic-xml.d.ts.map +1 -1
- package/dist/formatters/anthropic-xml.js +30 -2
- package/dist/formatters/anthropic-xml.js.map +1 -1
- package/dist/membrane.d.ts.map +1 -1
- package/dist/membrane.js +93 -2
- package/dist/membrane.js.map +1 -1
- package/dist/providers/anthropic.d.ts +9 -0
- package/dist/providers/anthropic.d.ts.map +1 -1
- package/dist/providers/anthropic.js +1 -1
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/bedrock.d.ts.map +1 -1
- package/dist/providers/bedrock.js +34 -4
- package/dist/providers/bedrock.js.map +1 -1
- package/dist/types/content.d.ts +5 -0
- package/dist/types/content.d.ts.map +1 -1
- package/dist/types/content.js.map +1 -1
- package/dist/types/streaming.d.ts +14 -0
- package/dist/types/streaming.d.ts.map +1 -1
- package/dist/types/tools.d.ts +19 -0
- package/dist/types/tools.d.ts.map +1 -1
- package/dist/types/yielding-stream.d.ts +45 -1
- package/dist/types/yielding-stream.d.ts.map +1 -1
- package/dist/types/yielding-stream.js.map +1 -1
- package/dist/utils/tool-parser.d.ts +16 -2
- package/dist/utils/tool-parser.d.ts.map +1 -1
- package/dist/utils/tool-parser.js +143 -35
- package/dist/utils/tool-parser.js.map +1 -1
- package/package.json +1 -1
- package/src/formatters/anthropic-xml.ts +30 -2
- package/src/membrane.ts +118 -2
- package/src/providers/anthropic.ts +1 -1
- package/src/providers/bedrock.ts +35 -3
- package/src/types/content.ts +5 -0
- package/src/types/streaming.ts +15 -0
- package/src/types/tools.ts +20 -0
- package/src/types/yielding-stream.ts +47 -0
- package/src/utils/tool-parser.ts +147 -36
package/src/membrane.ts
CHANGED
|
@@ -105,6 +105,10 @@ export class Membrane {
|
|
|
105
105
|
const startTime = Date.now();
|
|
106
106
|
let attempts = 0;
|
|
107
107
|
let rawRequest: unknown;
|
|
108
|
+
// Counted separately from `attempts` (the transport-error budget): a
|
|
109
|
+
// refusal is a successful HTTP call with an unwanted verdict, and letting
|
|
110
|
+
// it consume error retries would couple two unrelated budgets.
|
|
111
|
+
let refusalRetriesUsed = 0;
|
|
108
112
|
|
|
109
113
|
while (true) {
|
|
110
114
|
attempts++;
|
|
@@ -140,6 +144,19 @@ export class Membrane {
|
|
|
140
144
|
rawRequest
|
|
141
145
|
);
|
|
142
146
|
|
|
147
|
+
// Re-issue a content-policy refusal (opt-in, default off). Safe here
|
|
148
|
+
// in a way the streaming paths are not: nothing has reached the
|
|
149
|
+
// caller yet, so the abandoned attempt leaves no trace to retract.
|
|
150
|
+
// Deliberately BEFORE afterResponse — a hook that logs or transforms
|
|
151
|
+
// should see the attempt that actually stands, not the discarded one.
|
|
152
|
+
if (
|
|
153
|
+
response.stopReason === 'refusal' &&
|
|
154
|
+
refusalRetriesUsed < Math.max(0, options.refusalRetries ?? 0)
|
|
155
|
+
) {
|
|
156
|
+
refusalRetriesUsed++;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
|
|
143
160
|
// Call afterResponse hook
|
|
144
161
|
if (this.config.hooks?.afterResponse) {
|
|
145
162
|
return await this.config.hooks.afterResponse(response, providerResponse.raw);
|
|
@@ -391,10 +408,16 @@ export class Membrane {
|
|
|
391
408
|
// Track the initial prefill length so we can extract only NEW content for response
|
|
392
409
|
// Also track what block type we're inside at the end of prefill
|
|
393
410
|
let initialPrefillLength = 0;
|
|
411
|
+
// Watermark for per-round delta text (ToolContext.roundPreamble): start of
|
|
412
|
+
// the CURRENT round's model text in parser-accumulated coordinates.
|
|
413
|
+
// Advanced past each round's injected results push, so injected
|
|
414
|
+
// <function_results> XML never enters a round's delta.
|
|
415
|
+
let roundStartLen = 0;
|
|
394
416
|
let initialBlockType: 'thinking' | 'tool_call' | 'tool_result' | null = null;
|
|
395
417
|
if (prefillResult.assistantPrefill) {
|
|
396
418
|
parser.push(prefillResult.assistantPrefill);
|
|
397
419
|
initialPrefillLength = prefillResult.assistantPrefill.length;
|
|
420
|
+
roundStartLen = initialPrefillLength;
|
|
398
421
|
// Capture what block type we're inside after prefill (if any)
|
|
399
422
|
if (parser.isInsideBlock()) {
|
|
400
423
|
const blockType = parser.getCurrentBlockType();
|
|
@@ -682,6 +705,7 @@ export class Membrane {
|
|
|
682
705
|
const context: ToolContext = {
|
|
683
706
|
rawText: parsed.fullMatch,
|
|
684
707
|
preamble: parsed.beforeText.slice(initialPrefillLength),
|
|
708
|
+
roundPreamble: parsed.beforeText.slice(roundStartLen),
|
|
685
709
|
depth: toolDepth,
|
|
686
710
|
previousResults: executedToolResults,
|
|
687
711
|
accumulated: parser.getAccumulated().slice(initialPrefillLength),
|
|
@@ -694,6 +718,14 @@ export class Membrane {
|
|
|
694
718
|
);
|
|
695
719
|
}
|
|
696
720
|
|
|
721
|
+
// Backfill tool names for the legacy XML result rendering
|
|
722
|
+
// (<result><tool_name>…</tool_name><stdout>…) when the executor
|
|
723
|
+
// didn't supply them.
|
|
724
|
+
const callNames = new Map(parsed.calls.map((c) => [c.id, c.name]));
|
|
725
|
+
for (const r of results) {
|
|
726
|
+
if (!r.toolName) r.toolName = callNames.get(r.toolUseId);
|
|
727
|
+
}
|
|
728
|
+
|
|
697
729
|
// Track the tool results
|
|
698
730
|
executedToolResults.push(...results);
|
|
699
731
|
|
|
@@ -815,6 +847,10 @@ export class Membrane {
|
|
|
815
847
|
);
|
|
816
848
|
}
|
|
817
849
|
|
|
850
|
+
// Next round's model text starts after everything injected this
|
|
851
|
+
// round (results XML, image-split tags, thinking opener).
|
|
852
|
+
roundStartLen = parser.getAccumulated().length;
|
|
853
|
+
|
|
818
854
|
// Reset parser state for new streaming iteration. Tool rounds
|
|
819
855
|
// are the caller's work — they count against maxToolDepth only,
|
|
820
856
|
// never against the resumption guards (issue #39 review).
|
|
@@ -1681,6 +1717,23 @@ export class Membrane {
|
|
|
1681
1717
|
* separate `streamOnceWithoutHook` so the bypass is intentional.
|
|
1682
1718
|
*/
|
|
1683
1719
|
normalizedRequest: NormalizedRequest;
|
|
1720
|
+
/**
|
|
1721
|
+
* Re-issue this attempt when the provider ends it with
|
|
1722
|
+
* `stop_reason: 'refusal'` (see RetryingEvent). Default 0 = off, so
|
|
1723
|
+
* every existing caller keeps byte-identical behaviour.
|
|
1724
|
+
*/
|
|
1725
|
+
refusalRetries?: number;
|
|
1726
|
+
/**
|
|
1727
|
+
* REQUIRED to enable streaming retries. Called immediately before a
|
|
1728
|
+
* re-issue so the caller can discard the abandoned attempt: reset its
|
|
1729
|
+
* accumulators and tell its own consumer to drop what it emitted.
|
|
1730
|
+
*
|
|
1731
|
+
* Without it a retry would silently concatenate two attempts, so a
|
|
1732
|
+
* caller that does not pass this simply does not get retries — an
|
|
1733
|
+
* unaware consumer can never be corrupted by enabling the option
|
|
1734
|
+
* somewhere upstream.
|
|
1735
|
+
*/
|
|
1736
|
+
onRetrying?: (info: { attempt: number; maxAttempts: number; category?: string }) => void;
|
|
1684
1737
|
}
|
|
1685
1738
|
) {
|
|
1686
1739
|
// Strip `normalizedRequest` before forwarding to the adapter — it's
|
|
@@ -1688,9 +1741,21 @@ export class Membrane {
|
|
|
1688
1741
|
// compatibility won't catch the excess field (checked only on object
|
|
1689
1742
|
// literals, not on variables). Leaving it in would silently leak the
|
|
1690
1743
|
// normalized form into every adapter's options.
|
|
1691
|
-
const { normalizedRequest, ...adapterOptions } = options;
|
|
1744
|
+
const { normalizedRequest, refusalRetries, onRetrying, ...adapterOptions } = options;
|
|
1692
1745
|
const finalRequest = (await this.applyBeforeRequestHook(normalizedRequest, request)) as typeof request;
|
|
1693
|
-
|
|
1746
|
+
|
|
1747
|
+
// Retries are only safe when the caller can discard the abandoned
|
|
1748
|
+
// attempt, so they require BOTH a budget and an onRetrying hook.
|
|
1749
|
+
const maxAttempts = onRetrying ? Math.max(0, refusalRetries ?? 0) : 0;
|
|
1750
|
+
let retried = 0;
|
|
1751
|
+
while (true) {
|
|
1752
|
+
const result = await this.adapter.stream(finalRequest, callbacks, adapterOptions);
|
|
1753
|
+
if (result.stopReason !== 'refusal' || retried >= maxAttempts) return result;
|
|
1754
|
+
retried++;
|
|
1755
|
+
const category = (result.raw as { response?: { stop_details?: { category?: string } } } | undefined)
|
|
1756
|
+
?.response?.stop_details?.category;
|
|
1757
|
+
onRetrying!({ attempt: retried, maxAttempts, category });
|
|
1758
|
+
}
|
|
1694
1759
|
}
|
|
1695
1760
|
|
|
1696
1761
|
private buildContinuationRequest(
|
|
@@ -2172,6 +2237,18 @@ export class Membrane {
|
|
|
2172
2237
|
): YieldingStream {
|
|
2173
2238
|
const toolMode = this.resolveToolMode(request);
|
|
2174
2239
|
|
|
2240
|
+
// refusalRetries is implemented on the native path only. The XML path
|
|
2241
|
+
// accumulates into a streaming parser carrying prefill context and
|
|
2242
|
+
// resumption depths; rolling that back mid-turn is a separate problem,
|
|
2243
|
+
// and a partial implementation would corrupt the turn instead of
|
|
2244
|
+
// retrying it. Fail LOUD and OFF rather than silently mis-retrying.
|
|
2245
|
+
if (toolMode !== 'native' && (options.refusalRetries ?? 0) > 0) {
|
|
2246
|
+
(this.config.logger ?? console).warn(
|
|
2247
|
+
'[membrane] refusalRetries is ignored in XML tool mode ' +
|
|
2248
|
+
'(native-only for now) — the turn will surface the refusal as before.',
|
|
2249
|
+
);
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2175
2252
|
// Create the yielding stream with the appropriate inference runner
|
|
2176
2253
|
const runInference = toolMode === 'native'
|
|
2177
2254
|
? (stream: YieldingStreamImpl) => this.runNativeToolsYielding(request, options, stream)
|
|
@@ -2256,10 +2333,14 @@ export class Membrane {
|
|
|
2256
2333
|
|
|
2257
2334
|
// Initialize parser with prefill content
|
|
2258
2335
|
let initialPrefillLength = 0;
|
|
2336
|
+
// Watermark for per-round delta text (ToolContext.roundPreamble) — see
|
|
2337
|
+
// streamWithXmlTools for rationale. Advanced past each results push.
|
|
2338
|
+
let roundStartLen = 0;
|
|
2259
2339
|
let initialBlockType: 'thinking' | 'tool_call' | 'tool_result' | null = null;
|
|
2260
2340
|
if (prefillResult.assistantPrefill) {
|
|
2261
2341
|
parser.push(prefillResult.assistantPrefill);
|
|
2262
2342
|
initialPrefillLength = prefillResult.assistantPrefill.length;
|
|
2343
|
+
roundStartLen = initialPrefillLength;
|
|
2263
2344
|
if (parser.isInsideBlock()) {
|
|
2264
2345
|
const blockType = parser.getCurrentBlockType();
|
|
2265
2346
|
if (blockType === 'thinking' || blockType === 'tool_call' || blockType === 'tool_result') {
|
|
@@ -2506,6 +2587,7 @@ export class Membrane {
|
|
|
2506
2587
|
const context: ToolContext = {
|
|
2507
2588
|
rawText: parsed.fullMatch,
|
|
2508
2589
|
preamble: parsed.beforeText.slice(initialPrefillLength),
|
|
2590
|
+
roundPreamble: parsed.beforeText.slice(roundStartLen),
|
|
2509
2591
|
depth: toolDepth,
|
|
2510
2592
|
previousResults: executedToolResults,
|
|
2511
2593
|
accumulated: parser.getAccumulated().slice(initialPrefillLength),
|
|
@@ -2520,6 +2602,14 @@ export class Membrane {
|
|
|
2520
2602
|
|
|
2521
2603
|
const { results, injectedMessages } = await stream.requestToolExecution(toolCallsEvent);
|
|
2522
2604
|
|
|
2605
|
+
// Backfill tool names for the legacy XML result rendering
|
|
2606
|
+
// (<result><tool_name>…</tool_name><stdout>…) when the executor
|
|
2607
|
+
// didn't supply them.
|
|
2608
|
+
const yieldCallNames = new Map(parsed.calls.map((c) => [c.id, c.name]));
|
|
2609
|
+
for (const r of results) {
|
|
2610
|
+
if (!r.toolName) r.toolName = yieldCallNames.get(r.toolUseId);
|
|
2611
|
+
}
|
|
2612
|
+
|
|
2523
2613
|
// Mid-turn injected messages are not supported on the XML prefill
|
|
2524
2614
|
// path: the continuation is an assistant prefill over an XML
|
|
2525
2615
|
// transcript, not a message array, so there is no user envelope
|
|
@@ -2667,6 +2757,10 @@ export class Membrane {
|
|
|
2667
2757
|
);
|
|
2668
2758
|
}
|
|
2669
2759
|
|
|
2760
|
+
// Next round's model text starts after everything injected this
|
|
2761
|
+
// round (results XML, image-split tags, thinking opener).
|
|
2762
|
+
roundStartLen = parser.getAccumulated().length;
|
|
2763
|
+
|
|
2670
2764
|
// Tool rounds are the caller's work — they count against
|
|
2671
2765
|
// maxToolDepth only, never against the resumption guards
|
|
2672
2766
|
// (issue #39 review: the uncapped tool-loop contract stands).
|
|
@@ -2824,6 +2918,9 @@ export class Membrane {
|
|
|
2824
2918
|
// Stream from provider
|
|
2825
2919
|
let textAccumulated = '';
|
|
2826
2920
|
let blockIndex = 0;
|
|
2921
|
+
// Where this attempt starts inside the tool-loop-spanning buffer, so
|
|
2922
|
+
// a refusal retry can roll back exactly this attempt's contribution.
|
|
2923
|
+
const allTextBefore = allTextAccumulated.length;
|
|
2827
2924
|
// Track block-type from the provider's content_block_start signal so
|
|
2828
2925
|
// every token chunk is tagged with the membrane block it belongs to.
|
|
2829
2926
|
// Without this, thinking_delta chunks get mislabelled as 'text' and
|
|
@@ -2902,6 +2999,25 @@ export class Membrane {
|
|
|
2902
2999
|
idleTimeoutMs: options.idleTimeoutMs,
|
|
2903
3000
|
normalizedRequest: request,
|
|
2904
3001
|
onRequest: (req: unknown) => { rawRequest = req; },
|
|
3002
|
+
refusalRetries: options.refusalRetries,
|
|
3003
|
+
// Discard the refused attempt: roll the accumulators back to
|
|
3004
|
+
// where this attempt began and tell the consumer to drop what it
|
|
3005
|
+
// already received. `allTextAccumulated` spans the whole tool
|
|
3006
|
+
// loop, so it is truncated rather than cleared.
|
|
3007
|
+
onRetrying: (info) => {
|
|
3008
|
+
allTextAccumulated = allTextAccumulated.slice(0, allTextBefore);
|
|
3009
|
+
textAccumulated = '';
|
|
3010
|
+
blockIndex = 0;
|
|
3011
|
+
currentBlockType = 'text';
|
|
3012
|
+
seenBlockIndices.clear();
|
|
3013
|
+
stream.emit({
|
|
3014
|
+
type: 'retrying',
|
|
3015
|
+
attempt: info.attempt,
|
|
3016
|
+
maxAttempts: info.maxAttempts,
|
|
3017
|
+
reason: 'refusal',
|
|
3018
|
+
...(info.category ? { category: info.category } : {}),
|
|
3019
|
+
});
|
|
3020
|
+
},
|
|
2905
3021
|
}
|
|
2906
3022
|
);
|
|
2907
3023
|
|
|
@@ -780,7 +780,7 @@ function toAnthropicToolResultContent(
|
|
|
780
780
|
* can lose or mislabel mediaType (e.g. a PNG tagged image/jpeg), which the
|
|
781
781
|
* Anthropic API rejects with a 400. Trust the bytes; fall back to the declared
|
|
782
782
|
* type, then jpeg. */
|
|
783
|
-
function detectImageMediaType(data: string | undefined, fallback?: string): string {
|
|
783
|
+
export function detectImageMediaType(data: string | undefined, fallback?: string): string {
|
|
784
784
|
try {
|
|
785
785
|
const b = Buffer.from((data || "").slice(0, 24), "base64");
|
|
786
786
|
if (b[0]===0x89&&b[1]===0x50&&b[2]===0x4e&&b[3]===0x47) return "image/png";
|
package/src/providers/bedrock.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
INTERLEAVED_THINKING_BETA,
|
|
26
26
|
needsInterleavedThinkingBeta,
|
|
27
27
|
thinkingEnabled,
|
|
28
|
+
detectImageMediaType,
|
|
28
29
|
} from './anthropic.js';
|
|
29
30
|
|
|
30
31
|
// ============================================================================
|
|
@@ -322,8 +323,9 @@ export class BedrockAdapter implements ProviderAdapter {
|
|
|
322
323
|
|
|
323
324
|
// If already in Bedrock format, use as-is
|
|
324
325
|
// Supports both direct model IDs (anthropic.claude-...) and
|
|
325
|
-
// cross-region inference profile IDs (us.anthropic.claude-..., eu.anthropic.claude-...,
|
|
326
|
-
|
|
326
|
+
// cross-region inference profile IDs (us.anthropic.claude-..., eu.anthropic.claude-...,
|
|
327
|
+
// apac.anthropic.claude-..., global.anthropic.claude-... — `global` is 6 chars, hence {2,6}).
|
|
328
|
+
if (modelId.startsWith('anthropic.') || /^[a-z]{2,6}\.anthropic\./.test(modelId)) {
|
|
327
329
|
return modelId;
|
|
328
330
|
}
|
|
329
331
|
|
|
@@ -403,6 +405,25 @@ export class BedrockAdapter implements ProviderAdapter {
|
|
|
403
405
|
// caching works without the field, so strip just the ttl and keep the
|
|
404
406
|
// breakpoint. Transport quirks belong to the transport, not to every
|
|
405
407
|
// caller that sets cacheTtl. (Connectome issue #35.)
|
|
408
|
+
// Bedrock is strict about the wire shape of image sources: internal blocks
|
|
409
|
+
// carry camelCase `mediaType`, the API requires snake_case `media_type`.
|
|
410
|
+
// The Anthropic adapter converts on both paths (toAnthropicContent /
|
|
411
|
+
// toAnthropicToolResultContent); without the same conversion here a
|
|
412
|
+
// tool-returned image 400s with "media_type: Field required" mid-turn
|
|
413
|
+
// (observed on eidoverse snapshot results, 2026-08-08). Applies to
|
|
414
|
+
// top-level image blocks AND images nested inside tool_result content.
|
|
415
|
+
const toWireImage = (block: any): any => {
|
|
416
|
+
const source = block.source;
|
|
417
|
+
if (!source || source.type !== 'base64') return block;
|
|
418
|
+
const { mediaType, media_type, ...restSource } = source;
|
|
419
|
+
return {
|
|
420
|
+
...block,
|
|
421
|
+
source: {
|
|
422
|
+
...restSource,
|
|
423
|
+
media_type: detectImageMediaType(source.data, (media_type ?? mediaType) as string),
|
|
424
|
+
},
|
|
425
|
+
};
|
|
426
|
+
};
|
|
406
427
|
const sanitizedMessages = (request.messages as any[]).map((msg: any) => {
|
|
407
428
|
if (!Array.isArray(msg.content)) return msg;
|
|
408
429
|
return {
|
|
@@ -410,7 +431,18 @@ export class BedrockAdapter implements ProviderAdapter {
|
|
|
410
431
|
content: msg.content.map((block: any) => {
|
|
411
432
|
if (block.type === 'image' && block.sourceUrl !== undefined) {
|
|
412
433
|
const { sourceUrl, ...rest } = block;
|
|
413
|
-
return stripCacheTtl(rest);
|
|
434
|
+
return stripCacheTtl(toWireImage(rest));
|
|
435
|
+
}
|
|
436
|
+
if (block.type === 'image') {
|
|
437
|
+
return stripCacheTtl(toWireImage(block));
|
|
438
|
+
}
|
|
439
|
+
if (block.type === 'tool_result' && Array.isArray(block.content)) {
|
|
440
|
+
return stripCacheTtl({
|
|
441
|
+
...block,
|
|
442
|
+
content: block.content.map((inner: any) =>
|
|
443
|
+
inner?.type === 'image' ? toWireImage(inner) : inner,
|
|
444
|
+
),
|
|
445
|
+
});
|
|
414
446
|
}
|
|
415
447
|
return stripCacheTtl(block);
|
|
416
448
|
}),
|
package/src/types/content.ts
CHANGED
|
@@ -128,6 +128,11 @@ export interface ToolUseContent {
|
|
|
128
128
|
export interface ToolResultContent {
|
|
129
129
|
type: 'tool_result';
|
|
130
130
|
toolUseId: string;
|
|
131
|
+
/**
|
|
132
|
+
* Tool name, persisted so XML replay can reconstruct the legacy
|
|
133
|
+
* `<tool_name>` element byte-identically to the live injection.
|
|
134
|
+
*/
|
|
135
|
+
toolName?: string;
|
|
131
136
|
content: string | ContentBlock[];
|
|
132
137
|
isError?: boolean;
|
|
133
138
|
/**
|
package/src/types/streaming.ts
CHANGED
|
@@ -262,6 +262,21 @@ export interface CompleteOptions {
|
|
|
262
262
|
/** Abort signal for cancellation */
|
|
263
263
|
signal?: AbortSignal;
|
|
264
264
|
|
|
265
|
+
/**
|
|
266
|
+
* Re-issue the request when it ends with `stop_reason: 'refusal'`, up to
|
|
267
|
+
* this many times. Default 0 (off).
|
|
268
|
+
*
|
|
269
|
+
* Safe and invisible on this path: nothing has been emitted to the caller
|
|
270
|
+
* yet, so a discarded attempt leaves no trace beyond its output tokens
|
|
271
|
+
* (the replay is cache-warm on input). The streaming equivalent needs the
|
|
272
|
+
* caller to handle `RetryingEvent` — see YieldingStreamOptions.
|
|
273
|
+
*
|
|
274
|
+
* Near the content-policy threshold a refusal is probabilistic rather than
|
|
275
|
+
* a property of the payload, so re-asking is the cheapest correct response
|
|
276
|
+
* — cheaper and far less invasive than rewriting the conversation.
|
|
277
|
+
*/
|
|
278
|
+
refusalRetries?: number;
|
|
279
|
+
|
|
265
280
|
/** Request timeout */
|
|
266
281
|
timeoutMs?: number;
|
|
267
282
|
|
package/src/types/tools.ts
CHANGED
|
@@ -37,6 +37,13 @@ export interface ToolCall {
|
|
|
37
37
|
|
|
38
38
|
export interface ToolResult {
|
|
39
39
|
toolUseId: string;
|
|
40
|
+
/**
|
|
41
|
+
* Tool name, for the legacy XML result rendering
|
|
42
|
+
* (`<result><tool_name>…</tool_name><stdout>…</stdout></result>`).
|
|
43
|
+
* Optional: XML paths backfill it from the round's parsed calls when the
|
|
44
|
+
* executor didn't supply it.
|
|
45
|
+
*/
|
|
46
|
+
toolName?: string;
|
|
40
47
|
/**
|
|
41
48
|
* Result content - can be string or structured content blocks (for images).
|
|
42
49
|
* For XML mode, images are noted in text. For native mode, passed as content blocks.
|
|
@@ -63,6 +70,19 @@ export interface ToolContext {
|
|
|
63
70
|
/** Text before the tool calls (already streamed to user) */
|
|
64
71
|
preamble: string;
|
|
65
72
|
|
|
73
|
+
/**
|
|
74
|
+
* XML mode only: THIS round's model-authored text — the slice of the
|
|
75
|
+
* turn between the end of the previous round's injected results and this
|
|
76
|
+
* round's <function_calls> opener. Unlike `preamble` (cumulative: the
|
|
77
|
+
* whole turn so far, including harness-injected <function_results> XML),
|
|
78
|
+
* this never repeats earlier rounds and never contains injected results.
|
|
79
|
+
* Consumers persisting per-round assistant text must prefer this field —
|
|
80
|
+
* persisting the cumulative `preamble` per round stores each round's text
|
|
81
|
+
* N times and re-persists injected results as model text (the Evander
|
|
82
|
+
* 2026-08-08 scaffold-leak pyramid).
|
|
83
|
+
*/
|
|
84
|
+
roundPreamble?: string;
|
|
85
|
+
|
|
66
86
|
/** Current depth in tool execution loop */
|
|
67
87
|
depth: number;
|
|
68
88
|
|
|
@@ -69,6 +69,35 @@ export interface ErrorEvent {
|
|
|
69
69
|
error: Error;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Retrying event — the provider ended the attempt with
|
|
74
|
+
* `stop_reason: 'refusal'` and membrane is re-issuing it (opt-in via
|
|
75
|
+
* `refusalRetries`).
|
|
76
|
+
*
|
|
77
|
+
* **The consumer MUST discard everything this call has emitted so far**:
|
|
78
|
+
* `tokens`, `block`, and any partially built assistant content belong to an
|
|
79
|
+
* attempt that no longer exists. A fresh sequence follows. Consumers that
|
|
80
|
+
* have already shown those tokens to a human (a TUI, a chat surface) must
|
|
81
|
+
* retract or overwrite them.
|
|
82
|
+
*
|
|
83
|
+
* Why this exists: near the classifier threshold a refusal is probabilistic
|
|
84
|
+
* rather than a property of the payload — the same bytes pass and refuse
|
|
85
|
+
* minutes apart — so re-asking is the cheapest correct response. Retrying
|
|
86
|
+
* silently would corrupt any consumer that already rendered the discarded
|
|
87
|
+
* attempt, which is why it is opt-in and announced rather than invisible.
|
|
88
|
+
*/
|
|
89
|
+
export interface RetryingEvent {
|
|
90
|
+
type: 'retrying';
|
|
91
|
+
/** 1-based index of the retry about to be issued. */
|
|
92
|
+
attempt: number;
|
|
93
|
+
/** Configured maximum number of retries. */
|
|
94
|
+
maxAttempts: number;
|
|
95
|
+
/** Always 'refusal' today; widened only if other retryable stops appear. */
|
|
96
|
+
reason: 'refusal';
|
|
97
|
+
/** Provider's refusal category when it supplies one (e.g. 'cyber'). */
|
|
98
|
+
category?: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
72
101
|
/**
|
|
73
102
|
* Aborted event - stream was cancelled.
|
|
74
103
|
*/
|
|
@@ -87,6 +116,7 @@ export interface AbortedEvent {
|
|
|
87
116
|
export type StreamEvent =
|
|
88
117
|
| TokensEvent
|
|
89
118
|
| StreamBlockEvent
|
|
119
|
+
| RetryingEvent
|
|
90
120
|
| ToolCallsEvent
|
|
91
121
|
| UsageEvent
|
|
92
122
|
| CompleteEvent
|
|
@@ -225,6 +255,23 @@ export interface YieldingStreamOptions {
|
|
|
225
255
|
/** Request ID for correlation/logging */
|
|
226
256
|
requestId?: string;
|
|
227
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Re-issue an attempt that ends with `stop_reason: 'refusal'`, up to this
|
|
260
|
+
* many times. Default 0 (off).
|
|
261
|
+
*
|
|
262
|
+
* Enabling it means the stream can emit `RetryingEvent` — **consumers MUST
|
|
263
|
+
* handle it and discard what they have received for the call**, or two
|
|
264
|
+
* attempts will be concatenated. That is why it is off by default and why
|
|
265
|
+
* turning it on is a per-call decision by a consumer that has been updated.
|
|
266
|
+
*
|
|
267
|
+
* Rationale: near the content-policy threshold a refusal is probabilistic,
|
|
268
|
+
* not a property of the payload — identical bytes pass and refuse minutes
|
|
269
|
+
* apart. Re-asking is cheaper and less invasive than rewriting the
|
|
270
|
+
* conversation, and the replay is cache-warm, so only the discarded output
|
|
271
|
+
* tokens are real spend.
|
|
272
|
+
*/
|
|
273
|
+
refusalRetries?: number;
|
|
274
|
+
|
|
228
275
|
/**
|
|
229
276
|
* Maximum tool execution depth. Default: unlimited.
|
|
230
277
|
*
|