@animalabs/membrane 0.5.78 → 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 (58) 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/formatters/anthropic-xml.d.ts.map +1 -1
  14. package/dist/formatters/anthropic-xml.js +30 -2
  15. package/dist/formatters/anthropic-xml.js.map +1 -1
  16. package/dist/index.d.ts +2 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +2 -0
  19. package/dist/index.js.map +1 -1
  20. package/dist/membrane.d.ts +7 -1
  21. package/dist/membrane.d.ts.map +1 -1
  22. package/dist/membrane.js +148 -5
  23. package/dist/membrane.js.map +1 -1
  24. package/dist/providers/anthropic.d.ts +20 -0
  25. package/dist/providers/anthropic.d.ts.map +1 -1
  26. package/dist/providers/anthropic.js +23 -3
  27. package/dist/providers/anthropic.js.map +1 -1
  28. package/dist/providers/bedrock.d.ts.map +1 -1
  29. package/dist/providers/bedrock.js +31 -2
  30. package/dist/providers/bedrock.js.map +1 -1
  31. package/dist/types/config.d.ts +5 -0
  32. package/dist/types/config.d.ts.map +1 -1
  33. package/dist/types/config.js.map +1 -1
  34. package/dist/types/content.d.ts +5 -0
  35. package/dist/types/content.d.ts.map +1 -1
  36. package/dist/types/content.js.map +1 -1
  37. package/dist/types/request.d.ts +13 -0
  38. package/dist/types/request.d.ts.map +1 -1
  39. package/dist/types/tools.d.ts +19 -0
  40. package/dist/types/tools.d.ts.map +1 -1
  41. package/dist/utils/tool-parser.d.ts +16 -2
  42. package/dist/utils/tool-parser.d.ts.map +1 -1
  43. package/dist/utils/tool-parser.js +143 -35
  44. package/dist/utils/tool-parser.js.map +1 -1
  45. package/package.json +3 -2
  46. package/src/cache-keepalive.test.ts +244 -0
  47. package/src/cache-keepalive.ts +385 -0
  48. package/src/floating-cache-marker.test.ts +261 -0
  49. package/src/formatters/anthropic-xml.ts +30 -2
  50. package/src/index.ts +13 -0
  51. package/src/membrane.ts +143 -5
  52. package/src/providers/anthropic.ts +46 -3
  53. package/src/providers/bedrock.ts +32 -1
  54. package/src/types/config.ts +6 -0
  55. package/src/types/content.ts +5 -0
  56. package/src/types/request.ts +14 -0
  57. package/src/types/tools.ts +20 -0
  58. package/src/utils/tool-parser.ts +147 -36
@@ -0,0 +1,385 @@
1
+ // Prompt-cache keepalive for the Anthropic direct API.
2
+ //
3
+ // WHY
4
+ // ---
5
+ // Anthropic prompt cache entries expire on a TTL (1h for `cache_control.ttl:
6
+ // '1h'`), but **reading an entry restarts its clock** — the docs say the cache
7
+ // "is refreshed for no additional cost each time the cached content is used",
8
+ // and the lifetime is measured from the start of the request that *writes or
9
+ // reads* the entry. Verified empirically 2026-08-22: a 5m entry, poked with a
10
+ // `max_tokens: 0` request every 4 minutes, was still served as a pure read at
11
+ // t+12m (2.4x its nominal TTL), every poke reporting create=0 / read=6617.
12
+ //
13
+ // So an idle agent's context can be held warm indefinitely at cache-READ price
14
+ // (0.1x input) instead of paying a cache-WRITE (2x input) on its next wake.
15
+ // For a ~500k-token resident that is the difference between ~$0.50 and ~$10.00
16
+ // per wake. Measured on fable-cm's 11-day log (2026-08-11..22): 49.7M tokens of
17
+ // cache_creation occurred on turns that followed a >1h idle gap — $944 of write
18
+ // premium that a keepalive converts into ~$308 of reads.
19
+ //
20
+ // HOW
21
+ // ---
22
+ // We snapshot the exact wire request of each real call and replay it verbatim
23
+ // with `max_tokens: 0`, which runs prefill only: content `[]`, stop_reason
24
+ // `max_tokens`, zero output tokens billed, and the cache entry refreshed.
25
+ //
26
+ // ⚠️ THE REPLAY MUST BE BYTE-IDENTICAL ABOVE THE LAST BREAKPOINT.
27
+ // Prompt caching is a prefix match, and the API's invalidation hierarchy means
28
+ // some innocent-looking "normalizations" silently turn a 0.1x read into a 2x
29
+ // write. Verified the hard way on 2026-08-22: replaying with
30
+ // `thinking: {type:'disabled'}` instead of the request's own
31
+ // `thinking: {type:'adaptive'}` produced create=5081 / read=0 — a full rewrite,
32
+ // reported as a perfectly successful call. That failure is invisible unless you
33
+ // check the usage numbers, so `refresh()` below checks them on every single
34
+ // poke and disables the lineage rather than quietly burning 20x.
35
+ //
36
+ // Hence: we never rewrite the snapshot. We change `max_tokens` (not part of the
37
+ // cache key) and drop `stream` (a transport concern), and nothing else. Any
38
+ // request shape that can't tolerate `max_tokens: 0` is skipped outright rather
39
+ // than "fixed up" — see `ineligibleReason()`.
40
+
41
+ import { createHash } from 'node:crypto';
42
+
43
+ /** Minimal shape we need back from a keepalive send. */
44
+ export interface KeepaliveUsage {
45
+ // The SDK types these as `number | null`, and null is meaningfully different
46
+ // from 0 here: null means the field was absent (we learned nothing), 0 means
47
+ // the API told us nothing was read. Both are treated as "not a read" below.
48
+ cache_read_input_tokens?: number | null;
49
+ cache_creation_input_tokens?: number | null;
50
+ }
51
+
52
+ export type KeepaliveSend = (
53
+ wire: Record<string, unknown>,
54
+ headers: Record<string, string> | undefined,
55
+ ) => Promise<{ usage?: KeepaliveUsage }>;
56
+
57
+ export type KeepaliveLane = 'stream' | 'complete';
58
+
59
+ export type KeepaliveEvent =
60
+ | { type: 'refreshed'; key: string; lane: KeepaliveLane; readTokens: number; idleMs: number }
61
+ | { type: 'ineffective'; key: string; reason: string; readTokens: number; writeTokens: number }
62
+ | { type: 'skipped'; key: string; reason: string }
63
+ | { type: 'error'; key: string; error: string; consecutive: number }
64
+ | { type: 'disabled'; reason: string }
65
+ | { type: 'expired'; key: string; idleMs: number };
66
+
67
+ export interface CacheKeepaliveConfig {
68
+ /** Master switch. Default true. */
69
+ enabled?: boolean;
70
+ /**
71
+ * Stop refreshing once the last REAL request is this old. Keepalive pokes do
72
+ * not extend this — otherwise an agent that never speaks again would be kept
73
+ * warm forever. Default 24h.
74
+ */
75
+ maxIdleMs?: number;
76
+ /**
77
+ * Refresh once the entry hasn't been touched for this long. Must be < the
78
+ * cache TTL, with margin: the TTL clock starts at the *start* of the request,
79
+ * and a long streaming turn can itself eat minutes. Default 45m against a 1h
80
+ * TTL leaves 15m of headroom.
81
+ */
82
+ refreshAfterMs?: number;
83
+ /** Timer cadence. Default 5m. */
84
+ checkIntervalMs?: number;
85
+ /**
86
+ * Which lanes to keep warm. Default ['stream'] — the primary/voice lane.
87
+ * The aux ('complete') lane is measured to do no prompt caching at all today
88
+ * (fable-cm: 382 aux calls, every one create=0/read=0), so warming it would
89
+ * poke a cache entry that does not exist.
90
+ */
91
+ lanes?: KeepaliveLane[];
92
+ /** LRU cap on tracked lineages, to bound memory. Each holds a full wire
93
+ * request (~1.5MB for a 500k-token resident). Default 4. */
94
+ maxLineages?: number;
95
+ /** Consecutive send failures before the whole keepalive disables itself. */
96
+ maxConsecutiveErrors?: number;
97
+ /**
98
+ * How many times a lineage may come back as a WRITE instead of a read before
99
+ * we stop poking it. A lineage whose prefix churns every turn cannot be kept
100
+ * warm, and paying 2x to discover that repeatedly is the worst outcome.
101
+ */
102
+ maxIneffective?: number;
103
+ onEvent?: (event: KeepaliveEvent) => void;
104
+ }
105
+
106
+ interface Lineage {
107
+ wire: Record<string, unknown>;
108
+ headers: Record<string, string> | undefined;
109
+ lane: KeepaliveLane;
110
+ /** Last real (non-keepalive) request. Bounds the keepalive window. */
111
+ lastRealAt: number;
112
+ /** Last time the entry was touched by anything, real or keepalive. */
113
+ lastTouchAt: number;
114
+ ineffective: number;
115
+ }
116
+
117
+ const DEFAULTS = {
118
+ enabled: true,
119
+ maxIdleMs: 24 * 60 * 60 * 1000,
120
+ refreshAfterMs: 45 * 60 * 1000,
121
+ checkIntervalMs: 5 * 60 * 1000,
122
+ lanes: ['stream'] as KeepaliveLane[],
123
+ maxLineages: 4,
124
+ maxConsecutiveErrors: 3,
125
+ maxIneffective: 2,
126
+ };
127
+
128
+ /**
129
+ * Reasons a request shape cannot be safely replayed as `max_tokens: 0`.
130
+ *
131
+ * Each of these is either rejected outright by the API, or — worse — would
132
+ * require editing the request in a way that moves the cache-invalidation
133
+ * boundary. Skipping is always cheaper than guessing.
134
+ */
135
+ export function ineligibleReason(wire: Record<string, unknown>): string | null {
136
+ const thinking = wire.thinking as { type?: string } | undefined;
137
+ // `max_tokens: 0` is rejected with thinking.type 'enabled', and we must not
138
+ // "fix" that by disabling thinking — toggling thinking invalidates the
139
+ // messages cache (measured: create=5081/read=0).
140
+ if (thinking?.type === 'enabled') return 'legacy-thinking-budget';
141
+
142
+ const toolChoice = wire.tool_choice as { type?: string } | undefined;
143
+ // Rejected with max_tokens: 0, and tool_choice changes invalidate the
144
+ // messages cache, so we cannot substitute 'auto'.
145
+ if (toolChoice?.type === 'tool' || toolChoice?.type === 'any') return 'forced-tool-choice';
146
+
147
+ const outputConfig = wire.output_config as { format?: unknown } | undefined;
148
+ if (outputConfig?.format) return 'structured-output';
149
+
150
+ // Only the 1h cache is worth a background timer. A 5m entry would need a poke
151
+ // every ~4 minutes; at 0.1x of a large prefix that costs more than it saves.
152
+ const markers = scanCacheMarkers(wire);
153
+ if (!markers.any) return 'no-cache-breakpoint';
154
+ if (!markers.oneHour) return 'no-1h-breakpoint';
155
+
156
+ return null;
157
+ }
158
+
159
+ /**
160
+ * Walk system + tools + messages for cache_control markers.
161
+ *
162
+ * This runs on every outbound request, and `messages` on a large resident is
163
+ * megabytes of blocks — so it short-circuits the moment it finds a 1h marker,
164
+ * which is the answer in the overwhelmingly common case.
165
+ */
166
+ function scanCacheMarkers(wire: Record<string, unknown>): { any: boolean; oneHour: boolean } {
167
+ let any = false;
168
+ let oneHour = false;
169
+
170
+ const visit = (node: unknown): void => {
171
+ if (oneHour) return; // nothing left to learn
172
+ if (Array.isArray(node)) {
173
+ for (const item of node) {
174
+ visit(item);
175
+ if (oneHour) return;
176
+ }
177
+ return;
178
+ }
179
+ if (!node || typeof node !== 'object') return;
180
+ const obj = node as Record<string, unknown>;
181
+ const cc = obj.cache_control as { ttl?: string } | undefined;
182
+ if (cc && typeof cc === 'object') {
183
+ any = true;
184
+ if ((cc.ttl ?? '5m') === '1h') {
185
+ oneHour = true;
186
+ return;
187
+ }
188
+ }
189
+ if (obj.content) visit(obj.content);
190
+ };
191
+
192
+ visit(wire.system);
193
+ visit(wire.tools);
194
+ visit(wire.messages);
195
+ return { any, oneHour };
196
+ }
197
+
198
+ /**
199
+ * Identity of a cache lineage: model + system + tools. This is exactly the root
200
+ * of the cached prefix, so two agents in one process, or an agent's primary vs
201
+ * aux lane, land in different buckets automatically.
202
+ */
203
+ export function lineageKey(wire: Record<string, unknown>): string {
204
+ const h = createHash('sha256');
205
+ h.update(String(wire.model ?? ''));
206
+ h.update('');
207
+ h.update(JSON.stringify(wire.system ?? null));
208
+ h.update('');
209
+ h.update(JSON.stringify(wire.tools ?? null));
210
+ return h.digest('hex').slice(0, 16);
211
+ }
212
+
213
+ export class CacheKeepalive {
214
+ private lineages = new Map<string, Lineage>();
215
+ private timer: ReturnType<typeof setInterval> | null = null;
216
+ private ticking = false;
217
+ private consecutiveErrors = 0;
218
+ private stopped = false;
219
+ private readonly cfg: Required<Omit<CacheKeepaliveConfig, 'onEvent'>> & {
220
+ onEvent?: (event: KeepaliveEvent) => void;
221
+ };
222
+
223
+ constructor(private readonly send: KeepaliveSend, config: CacheKeepaliveConfig = {}) {
224
+ this.cfg = { ...DEFAULTS, ...config };
225
+ if (!this.cfg.enabled) this.stopped = true;
226
+ }
227
+
228
+ /** Record a real outbound request. Cheap; called on every LLM call. */
229
+ record(
230
+ wire: Record<string, unknown>,
231
+ headers: Record<string, string> | undefined,
232
+ lane: KeepaliveLane,
233
+ ): void {
234
+ if (this.stopped) return;
235
+ if (!this.cfg.lanes.includes(lane)) return;
236
+
237
+ const reason = ineligibleReason(wire);
238
+ const key = lineageKey(wire);
239
+ if (reason) {
240
+ // Drop any stale snapshot: the shape changed and is no longer warmable.
241
+ if (this.lineages.delete(key)) this.emit({ type: 'skipped', key, reason });
242
+ return;
243
+ }
244
+
245
+ const now = Date.now();
246
+ const existing = this.lineages.get(key);
247
+ // Re-insert to refresh LRU position.
248
+ this.lineages.delete(key);
249
+ this.lineages.set(key, {
250
+ wire,
251
+ headers,
252
+ lane,
253
+ lastRealAt: now,
254
+ lastTouchAt: now,
255
+ ineffective: existing?.ineffective ?? 0,
256
+ });
257
+
258
+ while (this.lineages.size > this.cfg.maxLineages) {
259
+ const oldest = this.lineages.keys().next().value as string | undefined;
260
+ if (oldest === undefined) break;
261
+ this.lineages.delete(oldest);
262
+ }
263
+
264
+ this.ensureTimer();
265
+ }
266
+
267
+ private ensureTimer(): void {
268
+ if (this.timer || this.stopped) return;
269
+ this.timer = setInterval(() => { void this.tick(); }, this.cfg.checkIntervalMs);
270
+ // Never hold the process open for a cache poke.
271
+ (this.timer as unknown as { unref?: () => void }).unref?.();
272
+ }
273
+
274
+ private async tick(): Promise<void> {
275
+ if (this.ticking || this.stopped) return;
276
+ this.ticking = true;
277
+ try {
278
+ const now = Date.now();
279
+ for (const [key, lin] of [...this.lineages]) {
280
+ if (this.stopped) break;
281
+ // The keepalive window is measured from the last REAL request, so pokes
282
+ // can never extend their own mandate.
283
+ if (now - lin.lastRealAt >= this.cfg.maxIdleMs) {
284
+ this.lineages.delete(key);
285
+ this.emit({ type: 'expired', key, idleMs: now - lin.lastRealAt });
286
+ continue;
287
+ }
288
+ // Idle-gated, not blind: if real traffic already touched the entry
289
+ // inside the window, it refreshed the TTL for free and we do nothing.
290
+ // This is what keeps a busy agent's keepalive cost at ~zero.
291
+ if (now - lin.lastTouchAt < this.cfg.refreshAfterMs) continue;
292
+ await this.refresh(key, lin);
293
+ }
294
+ } finally {
295
+ this.ticking = false;
296
+ }
297
+ }
298
+
299
+ private async refresh(key: string, lin: Lineage): Promise<void> {
300
+ // Only max_tokens (not part of the cache key) and stream (transport) differ
301
+ // from the recorded request. Nothing else is touched — see file header.
302
+ const payload: Record<string, unknown> = { ...lin.wire, max_tokens: 0 };
303
+ delete payload.stream;
304
+
305
+ const idleMs = Date.now() - lin.lastTouchAt;
306
+ try {
307
+ const res = await this.send(payload, lin.headers);
308
+ this.consecutiveErrors = 0;
309
+
310
+ const read = res.usage?.cache_read_input_tokens ?? 0;
311
+ const wrote = res.usage?.cache_creation_input_tokens ?? 0;
312
+
313
+ // The self-check. A keepalive that WRITES has not kept anything alive —
314
+ // it paid 2x to create a fresh entry, which is the exact failure this
315
+ // whole module exists to avoid. Never assume the poke worked.
316
+ if (read <= 0 || wrote > 0) {
317
+ lin.ineffective += 1;
318
+ this.emit({
319
+ type: 'ineffective',
320
+ key,
321
+ reason: wrote > 0 ? 'wrote-instead-of-read' : 'no-cache-read',
322
+ readTokens: read,
323
+ writeTokens: wrote,
324
+ });
325
+ if (lin.ineffective >= this.cfg.maxIneffective) {
326
+ this.lineages.delete(key);
327
+ } else {
328
+ lin.lastTouchAt = Date.now();
329
+ }
330
+ return;
331
+ }
332
+
333
+ lin.ineffective = 0;
334
+ lin.lastTouchAt = Date.now();
335
+ this.emit({ type: 'refreshed', key, lane: lin.lane, readTokens: read, idleMs });
336
+ } catch (err) {
337
+ this.consecutiveErrors += 1;
338
+ const message = err instanceof Error ? err.message : String(err);
339
+ this.emit({ type: 'error', key, error: message, consecutive: this.consecutiveErrors });
340
+
341
+ // Back this lineage off immediately rather than retrying on the next tick.
342
+ lin.lastTouchAt = Date.now();
343
+
344
+ // Hard breaker. A background loop that keeps firing failing requests is
345
+ // how fable-cm produced 1033 `400 invalid_request_error` rows in 3h on
346
+ // 2026-08-21 — the exact error class that also trips the agent's
347
+ // poison-history breaker. A keepalive must never be that loop.
348
+ if (this.consecutiveErrors >= this.cfg.maxConsecutiveErrors) {
349
+ this.stop();
350
+ this.emit({
351
+ type: 'disabled',
352
+ reason: `${this.consecutiveErrors} consecutive keepalive failures; last: ${message}`,
353
+ });
354
+ }
355
+ }
356
+ }
357
+
358
+ private emit(event: KeepaliveEvent): void {
359
+ try {
360
+ this.cfg.onEvent?.(event);
361
+ } catch {
362
+ // Observability must never break the keepalive, nor the caller.
363
+ }
364
+ }
365
+
366
+ /** Snapshot for operators / tests. */
367
+ getStatus(): Array<{ key: string; lane: KeepaliveLane; idleMs: number; realIdleMs: number }> {
368
+ const now = Date.now();
369
+ return [...this.lineages].map(([key, l]) => ({
370
+ key,
371
+ lane: l.lane,
372
+ idleMs: now - l.lastTouchAt,
373
+ realIdleMs: now - l.lastRealAt,
374
+ }));
375
+ }
376
+
377
+ stop(): void {
378
+ this.stopped = true;
379
+ if (this.timer) {
380
+ clearInterval(this.timer);
381
+ this.timer = null;
382
+ }
383
+ this.lineages.clear();
384
+ }
385
+ }
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Floating cache marker: incremental prompt caching inside the native
3
+ * tool loop (see the doctrine block in buildNativeToolRequest).
4
+ *
5
+ * Context strategies place breakpoints once per turn at compile time; the
6
+ * tool loop rebuilds the request every round with an append-only suffix
7
+ * the strategy never saw. The float rides the newest message using only
8
+ * the RESIDUAL breakpoint budget — upstream markers are never displaced.
9
+ * Motivating incident: qa-ops 2026-08-20, ~5.3M uncached tokens in 18min
10
+ * from two subagents whose single marker sat at message 2 of 61.
11
+ */
12
+ import { describe, it, expect, vi, afterEach } from 'vitest';
13
+ import { Membrane } from './membrane.js';
14
+ import type { NormalizedRequest, NormalizedMessage } from './types/index.js';
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Fixtures
18
+ // ---------------------------------------------------------------------------
19
+
20
+ const text = (t: string) => ({ type: 'text' as const, text: t });
21
+
22
+ const user = (t: string, bp = false): NormalizedMessage => ({
23
+ participant: 'User',
24
+ content: [text(t)],
25
+ ...(bp ? { cacheBreakpoint: true } : {}),
26
+ });
27
+
28
+ const assistantToolCall = (id: string): NormalizedMessage => ({
29
+ participant: 'Claude',
30
+ content: [
31
+ text('running a tool'),
32
+ { type: 'tool_use' as const, id, name: 'shell', input: { cmd: 'ls' } },
33
+ ],
34
+ });
35
+
36
+ const toolResults = (id: string, bp = false): NormalizedMessage => ({
37
+ participant: 'User',
38
+ content: [{ type: 'tool_result' as const, toolUseId: id, content: 'ok' }],
39
+ ...(bp ? { cacheBreakpoint: true } : {}),
40
+ });
41
+
42
+ /** A turn: marked kickoff + `rounds` completed tool rounds. */
43
+ function turn(rounds: number, kickoffMarked = true): NormalizedMessage[] {
44
+ const messages: NormalizedMessage[] = [user('do the thing', kickoffMarked)];
45
+ for (let i = 1; i <= rounds; i++) {
46
+ messages.push(assistantToolCall(`t${i}`), toolResults(`t${i}`));
47
+ }
48
+ return messages;
49
+ }
50
+
51
+ function makeRequest(overrides: Partial<NormalizedRequest> = {}): NormalizedRequest {
52
+ return {
53
+ messages: [],
54
+ system: 'You are a test agent.',
55
+ config: { model: 'claude-sonnet-5', maxTokens: 128 },
56
+ tools: [{ name: 'shell', description: 'run a command', inputSchema: { type: 'object' } }],
57
+ promptCaching: true,
58
+ cacheTtl: '1h',
59
+ assistantParticipant: 'Claude',
60
+ ...overrides,
61
+ } as NormalizedRequest;
62
+ }
63
+
64
+ function build(request: NormalizedRequest, messages: NormalizedMessage[], rebuild: boolean, membrane?: Membrane) {
65
+ const m = membrane ?? new Membrane({ name: 'anthropic' } as any);
66
+ return (m as any).buildNativeToolRequest(request, messages, rebuild);
67
+ }
68
+
69
+ /** Total cache_control instances across the whole wire request. */
70
+ function totalMarkers(pr: any): number {
71
+ let n = 0;
72
+ pr.messages.forEach((m: any) =>
73
+ (Array.isArray(m.content) ? m.content : []).forEach((b: any) => { if (b.cache_control) n++; }));
74
+ if (Array.isArray(pr.tools)) pr.tools.forEach((t: any) => { if (t.cache_control) n++; });
75
+ if (Array.isArray(pr.system)) pr.system.forEach((b: any) => { if (b.cache_control) n++; });
76
+ return n;
77
+ }
78
+
79
+ /** [messageIndex, blockIndex] of every message-level cache_control, plus tools/system markers. */
80
+ function markers(pr: any): { messages: Array<[number, number]>; onTools: boolean; onSystem: boolean } {
81
+ const msgs: Array<[number, number]> = [];
82
+ pr.messages.forEach((m: any, mi: number) => {
83
+ (Array.isArray(m.content) ? m.content : []).forEach((b: any, bi: number) => {
84
+ if (b.cache_control) msgs.push([mi, bi]);
85
+ });
86
+ });
87
+ const onTools = Array.isArray(pr.tools) && pr.tools.some((t: any) => t.cache_control);
88
+ const onSystem = Array.isArray(pr.system) && pr.system.some((b: any) => b.cache_control);
89
+ return { messages: msgs, onTools, onSystem };
90
+ }
91
+
92
+ const last = (pr: any) => pr.messages.length - 1;
93
+
94
+ afterEach(() => {
95
+ vi.restoreAllMocks();
96
+ });
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Tests
100
+ // ---------------------------------------------------------------------------
101
+
102
+ describe('floating cache marker', () => {
103
+ it('does not float on the turn\'s first build', () => {
104
+ const pr = build(makeRequest(), turn(1), false);
105
+ // Only the strategy's kickoff marker; the round's suffix is unmarked.
106
+ expect(markers(pr).messages).toEqual([[0, 0]]);
107
+ });
108
+
109
+ it('floats onto the newest message on a tool-loop rebuild', () => {
110
+ const pr = build(makeRequest(), turn(1), true);
111
+ const m = markers(pr);
112
+ // Kickoff marker intact + float on the final tool_result envelope.
113
+ expect(m.messages).toContainEqual([0, 0]);
114
+ expect(m.messages).toContainEqual([last(pr), 0]);
115
+ // Fallback stays suppressed: message markers exist.
116
+ expect(m.onTools).toBe(false);
117
+ expect(m.onSystem).toBe(false);
118
+ });
119
+
120
+ it('keeps the previous round\'s endpoint marked when budget allows', () => {
121
+ const pr = build(makeRequest(), turn(2), true);
122
+ const m = markers(pr);
123
+ // kickoff + previous round's results envelope + newest envelope.
124
+ expect(m.messages).toEqual([[0, 0], [last(pr) - 2, 0], [last(pr), 0]]);
125
+ });
126
+
127
+ it('never stacks a second marker on an already-marked final message', () => {
128
+ const messages = turn(2);
129
+ (messages[messages.length - 1] as any).cacheBreakpoint = true;
130
+ const pr = build(makeRequest(), messages, true);
131
+ const m = markers(pr);
132
+ const onFinal = m.messages.filter(([mi]) => mi === last(pr));
133
+ expect(onFinal).toHaveLength(1);
134
+ // Budget not consumed by the dedupe: previous endpoint still floated.
135
+ expect(m.messages).toContainEqual([last(pr) - 2, 0]);
136
+ });
137
+
138
+ it('withholds the float (with one warning) when upstream markers fill all 4 slots', () => {
139
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
140
+ const messages = turn(3);
141
+ // Mark every results envelope: 1 kickoff + 3 results = 4 upstream markers.
142
+ for (const msg of messages) {
143
+ if ((msg.content[0] as any).type === 'tool_result') (msg as any).cacheBreakpoint = true;
144
+ }
145
+ const membrane = new Membrane({ name: 'anthropic' } as any);
146
+ const pr = build(makeRequest(), messages, true, membrane);
147
+ expect(markers(pr).messages).toHaveLength(4);
148
+ expect(warn).toHaveBeenCalledTimes(1);
149
+ // Warn-once: a second rebuild does not warn again.
150
+ build(makeRequest(), messages, true, membrane);
151
+ expect(warn).toHaveBeenCalledTimes(1);
152
+ });
153
+
154
+ it('floats from the residuum left after the tools/system fallback on markerless requests', () => {
155
+ const pr = build(makeRequest(), turn(2, false), true);
156
+ const m = markers(pr);
157
+ // Fallback spent 2 (tools + system) → residuum 2 → newest + previous endpoint.
158
+ expect(m.onTools).toBe(true);
159
+ expect(m.onSystem).toBe(true);
160
+ expect(m.messages).toEqual([[last(pr) - 2, 0], [last(pr), 0]]);
161
+ });
162
+
163
+ it('steps back off a trailing thinking block', () => {
164
+ const messages = turn(1);
165
+ messages.push({
166
+ participant: 'Claude',
167
+ content: [text('partial'), { type: 'thinking' as const, thinking: 'hmm', signature: 'sig' }],
168
+ } as NormalizedMessage);
169
+ const pr = build(makeRequest(), messages, true);
170
+ const m = markers(pr);
171
+ // Marker lands on the text block, not the thinking block.
172
+ const finalMarks = m.messages.filter(([mi]) => mi === last(pr));
173
+ expect(finalMarks).toHaveLength(1);
174
+ const [, bi] = finalMarks[0]!;
175
+ expect((pr.messages[last(pr)].content[bi] as any).type).toBe('text');
176
+ });
177
+
178
+ it('stands down entirely when the normalizer synthesized a [pending] tool_result', () => {
179
+ // Trailing orphan tool_use → normalizer synthesizes its [pending]
180
+ // result, whose bytes change when the real result lands.
181
+ const messages = [user('go', true), assistantToolCall('t1')];
182
+ const pr = build(makeRequest(), messages, true);
183
+ // Only the kickoff marker; nothing floated at or past the synthetic.
184
+ expect(markers(pr).messages).toEqual([[0, 0]]);
185
+ });
186
+
187
+ it('respects request-level opt-out', () => {
188
+ const pr = build(makeRequest({ floatingCacheMarker: false }), turn(1), true);
189
+ expect(markers(pr).messages).toEqual([[0, 0]]);
190
+ });
191
+
192
+ it('emits no cache_control at all when promptCaching is off', () => {
193
+ const pr = build(makeRequest({ promptCaching: false }), turn(1), true);
194
+ const m = markers(pr);
195
+ expect(m.messages).toEqual([]);
196
+ expect(m.onTools).toBe(false);
197
+ expect(m.onSystem).toBe(false);
198
+ });
199
+
200
+ it('counts pre-marked system blocks against the residual budget (never exceeds 4 on the wire)', () => {
201
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
202
+ // System is a block array with one block already carrying cache_control —
203
+ // invisible to the running message tally, but a real wire marker.
204
+ const premarkedSystem = [
205
+ { type: 'text', text: 'You are a test agent.', cache_control: { type: 'ephemeral' } },
206
+ { type: 'text', text: 'Addendum.' },
207
+ ] as any;
208
+ // 3 message markers + pre-marked system = 4 on the wire: no room to float.
209
+ const full = turn(2);
210
+ for (const msg of full) {
211
+ if ((msg.content[0] as any).type === 'tool_result') (msg as any).cacheBreakpoint = true;
212
+ }
213
+ const prFull = build(makeRequest({ system: premarkedSystem }), full, true);
214
+ expect(totalMarkers(prFull)).toBe(4);
215
+ expect(warn).toHaveBeenCalledTimes(1);
216
+ // 2 message markers + pre-marked system = 3: exactly one slot left — the
217
+ // float takes the newest message and stops.
218
+ const partial = turn(2);
219
+ (partial[2] as any).cacheBreakpoint = true; // first round's results envelope
220
+ const prPartial = build(makeRequest({ system: premarkedSystem }), partial, true);
221
+ expect(totalMarkers(prPartial)).toBe(4);
222
+ expect(markers(prPartial).messages).toContainEqual([last(prPartial), 0]);
223
+ });
224
+
225
+ it('counts an overlapping block-level + message-level marker once (one physical marker)', () => {
226
+ // A stale block-level cache_control on the very block the message's
227
+ // cacheBreakpoint lands on is ONE wire marker, not two. Two such
228
+ // messages must leave residuum 2, not 0.
229
+ const staleMarked = (t: string): NormalizedMessage => ({
230
+ participant: 'User',
231
+ content: [{ type: 'text', text: t, cache_control: { type: 'ephemeral' } } as any],
232
+ cacheBreakpoint: true,
233
+ });
234
+ // Results envelope whose message-level breakpoint lands on a text block
235
+ // that already carries stale cache_control — tool pairing stays intact.
236
+ const overlapResults = (id: string): NormalizedMessage => ({
237
+ participant: 'User',
238
+ content: [
239
+ { type: 'tool_result' as const, toolUseId: id, content: 'ok' },
240
+ { type: 'text', text: 'operator note', cache_control: { type: 'ephemeral' } } as any,
241
+ ],
242
+ cacheBreakpoint: true,
243
+ });
244
+ const messages = [staleMarked('do the thing'),
245
+ assistantToolCall('t1'), overlapResults('t1'),
246
+ assistantToolCall('t2'), toolResults('t2')];
247
+ // 2 physical wire markers (the old tally saw 4 and withheld everything).
248
+ const pr = build(makeRequest(), messages, true);
249
+ const m = markers(pr);
250
+ // Float not withheld: newest message marked; previous endpoint already
251
+ // carries its own marker (dedupe, no slot spent).
252
+ expect(m.messages).toContainEqual([last(pr), 0]);
253
+ expect(totalMarkers(pr)).toBeLessThanOrEqual(4);
254
+ });
255
+
256
+ it('floated markers carry the request cacheTtl', () => {
257
+ const pr = build(makeRequest(), turn(1), true);
258
+ const [mi, bi] = markers(pr).messages.find(([i]) => i === last(pr))!;
259
+ expect((pr.messages[mi].content[bi] as any).cache_control).toEqual({ type: 'ephemeral', ttl: '1h' });
260
+ });
261
+ });