@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.
Files changed (38) hide show
  1. package/dist/cache-keepalive.d.ts +115 -0
  2. package/dist/cache-keepalive.d.ts.map +1 -0
  3. package/dist/cache-keepalive.js +0 -0
  4. package/dist/cache-keepalive.js.map +1 -0
  5. package/dist/cache-keepalive.test.d.ts +2 -0
  6. package/dist/cache-keepalive.test.d.ts.map +1 -0
  7. package/dist/cache-keepalive.test.js +206 -0
  8. package/dist/cache-keepalive.test.js.map +1 -0
  9. package/dist/floating-cache-marker.test.d.ts +2 -0
  10. package/dist/floating-cache-marker.test.d.ts.map +1 -0
  11. package/dist/floating-cache-marker.test.js +242 -0
  12. package/dist/floating-cache-marker.test.js.map +1 -0
  13. package/dist/index.d.ts +2 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +2 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/membrane.d.ts +7 -1
  18. package/dist/membrane.d.ts.map +1 -1
  19. package/dist/membrane.js +114 -5
  20. package/dist/membrane.js.map +1 -1
  21. package/dist/providers/anthropic.d.ts +11 -0
  22. package/dist/providers/anthropic.d.ts.map +1 -1
  23. package/dist/providers/anthropic.js +22 -2
  24. package/dist/providers/anthropic.js.map +1 -1
  25. package/dist/types/config.d.ts +5 -0
  26. package/dist/types/config.d.ts.map +1 -1
  27. package/dist/types/config.js.map +1 -1
  28. package/dist/types/request.d.ts +13 -0
  29. package/dist/types/request.d.ts.map +1 -1
  30. package/package.json +3 -2
  31. package/src/cache-keepalive.test.ts +244 -0
  32. package/src/cache-keepalive.ts +385 -0
  33. package/src/floating-cache-marker.test.ts +261 -0
  34. package/src/index.ts +13 -0
  35. package/src/membrane.ts +107 -5
  36. package/src/providers/anthropic.ts +45 -2
  37. package/src/types/config.ts +6 -0
  38. 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
- const normalized = normalizeToolPairs(providerMessages);
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: this.betaHeaders(request),
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
- if (message.includes('context') || message.includes('too long')) {
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
 
@@ -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.
@@ -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).