@animalabs/membrane 0.5.79 → 0.5.80
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/cache-keepalive.d.ts +115 -0
- package/dist/cache-keepalive.d.ts.map +1 -0
- package/dist/cache-keepalive.js +0 -0
- package/dist/cache-keepalive.js.map +1 -0
- package/dist/cache-keepalive.test.d.ts +2 -0
- package/dist/cache-keepalive.test.d.ts.map +1 -0
- package/dist/cache-keepalive.test.js +206 -0
- package/dist/cache-keepalive.test.js.map +1 -0
- package/dist/floating-cache-marker.test.d.ts +2 -0
- package/dist/floating-cache-marker.test.d.ts.map +1 -0
- package/dist/floating-cache-marker.test.js +242 -0
- package/dist/floating-cache-marker.test.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/membrane.d.ts +7 -1
- package/dist/membrane.d.ts.map +1 -1
- package/dist/membrane.js +114 -5
- package/dist/membrane.js.map +1 -1
- package/dist/providers/anthropic.d.ts +11 -0
- package/dist/providers/anthropic.d.ts.map +1 -1
- package/dist/providers/anthropic.js +22 -2
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/types/config.d.ts +5 -0
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/config.js.map +1 -1
- package/dist/types/request.d.ts +13 -0
- package/dist/types/request.d.ts.map +1 -1
- package/package.json +3 -2
- package/src/cache-keepalive.test.ts +244 -0
- package/src/cache-keepalive.ts +385 -0
- package/src/floating-cache-marker.test.ts +261 -0
- package/src/index.ts +13 -0
- package/src/membrane.ts +107 -5
- package/src/providers/anthropic.ts +45 -2
- package/src/types/config.ts +6 -0
- package/src/types/request.ts +14 -0
package/src/membrane.ts
CHANGED
|
@@ -1005,7 +1005,7 @@ export class Membrane {
|
|
|
1005
1005
|
// Tool execution loop
|
|
1006
1006
|
while (toolDepth <= maxToolDepth) {
|
|
1007
1007
|
// Build provider request with native tools
|
|
1008
|
-
const providerRequest = this.buildNativeToolRequest(request, messages);
|
|
1008
|
+
const providerRequest = this.buildNativeToolRequest(request, messages, toolDepth > 0);
|
|
1009
1009
|
|
|
1010
1010
|
// Stream from provider
|
|
1011
1011
|
let textAccumulated = '';
|
|
@@ -1197,12 +1197,20 @@ export class Membrane {
|
|
|
1197
1197
|
}
|
|
1198
1198
|
}
|
|
1199
1199
|
|
|
1200
|
+
/** See the floating-cache-marker block in buildNativeToolRequest. */
|
|
1201
|
+
private floatBudgetWarned = false;
|
|
1202
|
+
|
|
1200
1203
|
/**
|
|
1201
|
-
* Build a provider request with native tool support
|
|
1204
|
+
* Build a provider request with native tool support.
|
|
1205
|
+
*
|
|
1206
|
+
* `toolLoopRebuild` is true when this build is a tool-loop continuation
|
|
1207
|
+
* (toolDepth > 0) rather than the turn's first request — the only case
|
|
1208
|
+
* where the floating cache marker applies.
|
|
1202
1209
|
*/
|
|
1203
1210
|
private buildNativeToolRequest(
|
|
1204
1211
|
request: NormalizedRequest,
|
|
1205
|
-
messages: typeof request.messages
|
|
1212
|
+
messages: typeof request.messages,
|
|
1213
|
+
toolLoopRebuild = false
|
|
1206
1214
|
): any {
|
|
1207
1215
|
// Provider-native formatters own their complete input-item shape. The
|
|
1208
1216
|
// legacy implementation below is intentionally Anthropic-specific; using
|
|
@@ -1354,7 +1362,17 @@ export class Membrane {
|
|
|
1354
1362
|
// tool_results to `messages`. Any unmatched tool_use that reaches
|
|
1355
1363
|
// this splice is upstream stranding (the bug class this fix exists
|
|
1356
1364
|
// to catch) — `[pending]` is exactly the right synthesis.
|
|
1357
|
-
|
|
1365
|
+
// A synthesized [pending] tool_result's bytes are rewritten when the
|
|
1366
|
+
// real result lands — the floating-marker block below must not cache
|
|
1367
|
+
// past one. `synthetic_pending_result` (not the downstream
|
|
1368
|
+
// cache_suppressed_for_synthetic, which only fires when a marker was
|
|
1369
|
+
// actually stripped) is the root condition.
|
|
1370
|
+
let pendingResultSynthesized = false;
|
|
1371
|
+
const normalized = normalizeToolPairs(providerMessages, {
|
|
1372
|
+
onEvent: (e) => {
|
|
1373
|
+
if (e.kind === 'synthetic_pending_result') pendingResultSynthesized = true;
|
|
1374
|
+
},
|
|
1375
|
+
});
|
|
1358
1376
|
const mergedMessages = mergeConsecutiveRoles(normalized.messages);
|
|
1359
1377
|
|
|
1360
1378
|
// Convert tools to provider format.
|
|
@@ -1387,6 +1405,90 @@ export class Membrane {
|
|
|
1387
1405
|
);
|
|
1388
1406
|
}
|
|
1389
1407
|
|
|
1408
|
+
// ------------------------------------------------------------------
|
|
1409
|
+
// Floating cache marker: incremental prompt caching inside the native
|
|
1410
|
+
// tool loop. Message breakpoints are placed by the context strategy at
|
|
1411
|
+
// compile time — once per turn — but this builder re-runs on every
|
|
1412
|
+
// tool round with that round's messages appended, so the deepest
|
|
1413
|
+
// upstream marker stays glued to the turn-start snapshot and each
|
|
1414
|
+
// rebuild re-pays the entire appended suffix at full input price
|
|
1415
|
+
// (qa-ops incident, 2026-08-20: two subagents re-sent a suffix growing
|
|
1416
|
+
// to ~118k tokens ~30 times each — ~5.3M uncached tokens in 18 min —
|
|
1417
|
+
// with their one marker sitting on message 2 of 61).
|
|
1418
|
+
//
|
|
1419
|
+
// The tool loop only ever appends, so a marker riding the newest
|
|
1420
|
+
// message yields the intended incremental pattern: each round writes
|
|
1421
|
+
// its delta and cache-reads everything before it.
|
|
1422
|
+
//
|
|
1423
|
+
// Authority contract: the float spends only the RESIDUAL breakpoint
|
|
1424
|
+
// budget (Anthropic allows 4 cache_control including tools/system).
|
|
1425
|
+
// Upstream markers are never displaced or stripped — if they fill all
|
|
1426
|
+
// 4 slots the float is withheld (with a warning) and behavior is
|
|
1427
|
+
// exactly pre-float. With 2+ slots free, the previous round's
|
|
1428
|
+
// endpoint is marked too: a wide parallel-tool round can append more
|
|
1429
|
+
// blocks than the provider's ~20-block backward search covers, which
|
|
1430
|
+
// would orphan the previous round's cache entry behind an unmarked
|
|
1431
|
+
// boundary.
|
|
1432
|
+
//
|
|
1433
|
+
// Skipped when the normalizer synthesized a [pending] tool_result:
|
|
1434
|
+
// those bytes are rewritten when the real result lands, and caching
|
|
1435
|
+
// past them poisons the prefix — the same rationale as the
|
|
1436
|
+
// normalizer's phase 5.5 cache suppression.
|
|
1437
|
+
// ------------------------------------------------------------------
|
|
1438
|
+
const floatingEnabled =
|
|
1439
|
+
request.floatingCacheMarker ?? this.config.defaultFloatingCacheMarker ?? true;
|
|
1440
|
+
if (toolLoopRebuild && floatingEnabled && cacheControl && !pendingResultSynthesized) {
|
|
1441
|
+
// Residuum from a RECOUNT of the constructed wire artifacts, not the
|
|
1442
|
+
// running messageBreakpoints tally — the tally diverges from the wire
|
|
1443
|
+
// in both directions (mirrors NativeFormatter's recount, same bug
|
|
1444
|
+
// class as the Sill 2026-07-25 wedge): a message-level breakpoint
|
|
1445
|
+
// landing on a block already carrying stale cache_control is one
|
|
1446
|
+
// physical marker counted twice, and a pre-marked system block is a
|
|
1447
|
+
// real wire marker the tally never sees. Counted post-fallback and
|
|
1448
|
+
// post-normalize, so fallback spend and phase-5.5 suppression are
|
|
1449
|
+
// both reflected.
|
|
1450
|
+
let wireMarkers = 0;
|
|
1451
|
+
for (const m of mergedMessages) {
|
|
1452
|
+
if (!Array.isArray(m.content)) continue;
|
|
1453
|
+
for (const b of m.content as Array<Record<string, unknown>>) {
|
|
1454
|
+
if (b.cache_control) wireMarkers++;
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
if (tools) for (const t of tools) { if (t.cache_control) wireMarkers++; }
|
|
1458
|
+
if (Array.isArray(system)) {
|
|
1459
|
+
for (const b of system as Array<Record<string, unknown>>) {
|
|
1460
|
+
if (b.cache_control) wireMarkers++;
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
let residuum = 4 - wireMarkers;
|
|
1464
|
+
if (residuum <= 0) {
|
|
1465
|
+
if (!this.floatBudgetWarned) {
|
|
1466
|
+
this.floatBudgetWarned = true;
|
|
1467
|
+
console.warn(
|
|
1468
|
+
`[membrane] floating cache marker withheld: upstream markers already ` +
|
|
1469
|
+
`occupy all 4 cache_control slots (${wireMarkers} on the wire). ` +
|
|
1470
|
+
`Tool-round suffixes will not cache incrementally.`
|
|
1471
|
+
);
|
|
1472
|
+
}
|
|
1473
|
+
} else {
|
|
1474
|
+
// Newest message first; then the previous round's endpoint (two
|
|
1475
|
+
// wire messages back: [..., prevResults, assistant, results]).
|
|
1476
|
+
const targets = [mergedMessages.length - 1, mergedMessages.length - 3];
|
|
1477
|
+
for (const mi of targets) {
|
|
1478
|
+
if (residuum <= 0 || mi < 0) continue;
|
|
1479
|
+
const content = mergedMessages[mi]?.content;
|
|
1480
|
+
if (!Array.isArray(content) || content.length === 0) continue;
|
|
1481
|
+
const bpIdx = lastCacheableBlockIndex(content as Array<Record<string, unknown>>);
|
|
1482
|
+
if (bpIdx < 0) continue;
|
|
1483
|
+
// Already a breakpoint here (e.g. the strategy's own end marker
|
|
1484
|
+
// on the turn's first rebuild) — nothing to add.
|
|
1485
|
+
if ((content[bpIdx] as Record<string, unknown>).cache_control) continue;
|
|
1486
|
+
(content[bpIdx] as Record<string, unknown>).cache_control = cacheControl;
|
|
1487
|
+
residuum--;
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1390
1492
|
// Build thinking config for native extended thinking (budget clamped to max_tokens)
|
|
1391
1493
|
// Fable/Mythos models: thinking is always on and unconfigurable; sampling params are removed.
|
|
1392
1494
|
// Sending thinking config or temperature returns a 400 — omit both entirely.
|
|
@@ -2913,7 +3015,7 @@ export class Membrane {
|
|
|
2913
3015
|
}
|
|
2914
3016
|
|
|
2915
3017
|
// Build provider request with native tools
|
|
2916
|
-
const providerRequest = this.buildNativeToolRequest(request, messages);
|
|
3018
|
+
const providerRequest = this.buildNativeToolRequest(request, messages, toolDepth > 0);
|
|
2917
3019
|
|
|
2918
3020
|
// Stream from provider
|
|
2919
3021
|
let textAccumulated = '';
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
abortError,
|
|
23
23
|
} from '../types/index.js';
|
|
24
24
|
import { flattenRootSchemaUnion } from './anthropic-tool-schema.js';
|
|
25
|
+
import { CacheKeepalive, type CacheKeepaliveConfig } from '../cache-keepalive.js';
|
|
25
26
|
|
|
26
27
|
// ============================================================================
|
|
27
28
|
// Model capability gates
|
|
@@ -146,6 +147,15 @@ export interface AnthropicAdapterConfig {
|
|
|
146
147
|
|
|
147
148
|
/** Default max tokens */
|
|
148
149
|
defaultMaxTokens?: number;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Prompt-cache keepalive: hold this agent's cached prefix warm across idle
|
|
153
|
+
* gaps by replaying the last request with `max_tokens: 0`, which refreshes
|
|
154
|
+
* the entry's TTL at cache-READ price instead of letting it expire into a
|
|
155
|
+
* 2x cache write on the next wake. See `../cache-keepalive.ts`.
|
|
156
|
+
* Pass `{ enabled: false }` to turn off.
|
|
157
|
+
*/
|
|
158
|
+
cacheKeepalive?: CacheKeepaliveConfig;
|
|
149
159
|
}
|
|
150
160
|
|
|
151
161
|
// ============================================================================
|
|
@@ -161,6 +171,8 @@ export class AnthropicAdapter implements ProviderAdapter {
|
|
|
161
171
|
* the SDK rather than merging, so when we add a per-request beta we must
|
|
162
172
|
* re-carry this one alongside it or auth breaks. */
|
|
163
173
|
private defaultBeta: string | undefined;
|
|
174
|
+
/** Holds idle agents' cached prefixes warm; undefined when disabled. */
|
|
175
|
+
readonly cacheKeepalive: CacheKeepalive | undefined;
|
|
164
176
|
|
|
165
177
|
constructor(config: AnthropicAdapterConfig = {}) {
|
|
166
178
|
const clientOptions: ClientOptions = {
|
|
@@ -178,6 +190,19 @@ export class AnthropicAdapter implements ProviderAdapter {
|
|
|
178
190
|
|
|
179
191
|
this.client = new Anthropic(clientOptions);
|
|
180
192
|
this.defaultMaxTokens = config.defaultMaxTokens ?? 4096;
|
|
193
|
+
|
|
194
|
+
this.cacheKeepalive = config.cacheKeepalive?.enabled === false
|
|
195
|
+
? undefined
|
|
196
|
+
: new CacheKeepalive(
|
|
197
|
+
// Replay path. Deliberately bypasses buildRequest(): the payload is
|
|
198
|
+
// the already-built wire request from a real call, and rebuilding it
|
|
199
|
+
// risks a byte diff that silently converts a 0.1x read into a 2x write.
|
|
200
|
+
async (wire, headers) => await this.client.messages.create(
|
|
201
|
+
wire as unknown as Anthropic.MessageCreateParamsNonStreaming,
|
|
202
|
+
headers ? { headers } : undefined,
|
|
203
|
+
),
|
|
204
|
+
config.cacheKeepalive ?? {},
|
|
205
|
+
);
|
|
181
206
|
}
|
|
182
207
|
|
|
183
208
|
supportsModel(modelId: string): boolean {
|
|
@@ -192,10 +217,15 @@ export class AnthropicAdapter implements ProviderAdapter {
|
|
|
192
217
|
const fullRequest = { ...anthropicRequest, stream: false as const };
|
|
193
218
|
options?.onRequest?.(fullRequest);
|
|
194
219
|
|
|
220
|
+
const headers = this.betaHeaders(request);
|
|
221
|
+
this.cacheKeepalive?.record(
|
|
222
|
+
fullRequest as unknown as Record<string, unknown>, headers, 'complete',
|
|
223
|
+
);
|
|
224
|
+
|
|
195
225
|
try {
|
|
196
226
|
const response = await this.client.messages.create(fullRequest, {
|
|
197
227
|
signal: options?.signal,
|
|
198
|
-
headers
|
|
228
|
+
headers,
|
|
199
229
|
});
|
|
200
230
|
|
|
201
231
|
return this.parseResponse(response, fullRequest);
|
|
@@ -214,6 +244,14 @@ export class AnthropicAdapter implements ProviderAdapter {
|
|
|
214
244
|
const fullRequest = { ...anthropicRequest, stream: true };
|
|
215
245
|
options?.onRequest?.(fullRequest);
|
|
216
246
|
|
|
247
|
+
// Snapshot the primary lane's prefix so it can be held warm across idle
|
|
248
|
+
// gaps. `stream: true` is dropped at replay time (transport, not cache key).
|
|
249
|
+
this.cacheKeepalive?.record(
|
|
250
|
+
fullRequest as unknown as Record<string, unknown>,
|
|
251
|
+
this.betaHeaders(request),
|
|
252
|
+
'stream',
|
|
253
|
+
);
|
|
254
|
+
|
|
217
255
|
// Idle timeout: abort if no SSE event arrives within the deadline.
|
|
218
256
|
// The SDK's timeout only covers the initial HTTP response headers;
|
|
219
257
|
// once streaming starts, a silently dropped connection waits forever.
|
|
@@ -664,7 +702,12 @@ export class AnthropicAdapter implements ProviderAdapter {
|
|
|
664
702
|
return authError(message, error, rawRequest);
|
|
665
703
|
}
|
|
666
704
|
|
|
667
|
-
|
|
705
|
+
// Context-length is a client-side request-shape problem — it only ever
|
|
706
|
+
// arrives as a 400 (invalid_request_error). Without the status guard, a
|
|
707
|
+
// transient 5xx whose body happens to contain "context" or "too long"
|
|
708
|
+
// (e.g. "Internal error: context processing failed") was misclassified
|
|
709
|
+
// as non-retryable context_length, silently suppressing retries.
|
|
710
|
+
if (status === 400 && (message.includes('context') || message.includes('too long'))) {
|
|
668
711
|
return contextLengthError(message, error, rawRequest);
|
|
669
712
|
}
|
|
670
713
|
|
package/src/types/config.ts
CHANGED
|
@@ -180,6 +180,12 @@ export interface MembraneConfig {
|
|
|
180
180
|
*/
|
|
181
181
|
defaultPromptCaching?: boolean;
|
|
182
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Default for request.floatingCacheMarker when the request doesn't set it.
|
|
185
|
+
* Default: true. See NormalizedRequest.floatingCacheMarker.
|
|
186
|
+
*/
|
|
187
|
+
defaultFloatingCacheMarker?: boolean;
|
|
188
|
+
|
|
183
189
|
/**
|
|
184
190
|
* Prefill formatter for message serialization and response parsing.
|
|
185
191
|
* Controls how messages are formatted for the API and how responses are parsed.
|
package/src/types/request.ts
CHANGED
|
@@ -159,6 +159,20 @@ export interface NormalizedRequest {
|
|
|
159
159
|
*/
|
|
160
160
|
cacheTtl?: '5m' | '1h';
|
|
161
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Float a trailing cache_control marker onto the newest message when the
|
|
164
|
+
* native tool loop rebuilds the request between tool-execution rounds, so
|
|
165
|
+
* the growing tool-round suffix caches incrementally (each round writes
|
|
166
|
+
* only its delta and cache-reads everything before it). Placed only from
|
|
167
|
+
* the request's *residual* breakpoint budget — the marker is withheld when
|
|
168
|
+
* upstream markers already occupy all 4 Anthropic cache_control slots —
|
|
169
|
+
* so upstream breakpoints are never displaced or stripped.
|
|
170
|
+
* Defaults to true (when promptCaching is enabled). Set false for context
|
|
171
|
+
* strategies whose request prefix churns between rounds, where a trailing
|
|
172
|
+
* marker would be pure cache-write cost.
|
|
173
|
+
*/
|
|
174
|
+
floatingCacheMarker?: boolean;
|
|
175
|
+
|
|
162
176
|
/**
|
|
163
177
|
* Context prefix for simulacrum seeding.
|
|
164
178
|
* Injected as first assistant message (before conversation history).
|