|
| 1 | +// Prompt-only shrink after an Anthropic ephemeral cache write expires. |
| 2 | +// Compaction rewrites turns.jsonl and keeps a raw tail, so the next infer |
| 3 | +// still cache-writes those tool bodies. This transform runs inside |
| 4 | +// executeInfer: its output is what gets written to prompt.jsonl. It never |
| 5 | +// writes turns and never calls the compaction governor. |
| 6 | + |
| 7 | +import type { |
| 8 | + ContentBlock, |
| 9 | + ContextTransform, |
| 10 | + ConversationTurn, |
| 11 | +} from "@intx/types/runtime"; |
| 12 | + |
| 13 | +import { cacheTtlMsFor } from "../provider/cache-ttl.js"; |
| 14 | + |
| 15 | +export type AnthropicCachePromptDeps = { |
| 16 | + nowMs: () => number; |
| 17 | + /** Wall time of the last Anthropic-protocol cache write, if one was stamped. */ |
| 18 | + cacheWriteAt: () => number | undefined; |
| 19 | + /** Live adapter protocol (`InferenceSource.provider`). */ |
| 20 | + protocol: () => string | undefined; |
| 21 | +}; |
| 22 | + |
| 23 | +const STRATEGY = "anthropic-cache-prompt"; |
| 24 | + |
| 25 | +type ToolResultBlock = Extract<ContentBlock, { type: "tool_result" }>; |
| 26 | + |
| 27 | +function passthrough( |
| 28 | + turns: ConversationTurn[], |
| 29 | + reason: string, |
| 30 | +): Awaited<ReturnType<ContextTransform["apply"]>> { |
| 31 | + return { |
| 32 | + output: turns, |
| 33 | + record: { |
| 34 | + strategy: STRATEGY, |
| 35 | + version: "1", |
| 36 | + parameters: {}, |
| 37 | + reason, |
| 38 | + decisions: { stubbed: 0 }, |
| 39 | + }, |
| 40 | + }; |
| 41 | +} |
| 42 | + |
| 43 | +function newestAssistantTextIndex(turns: readonly ConversationTurn[]): number { |
| 44 | + for (let i = turns.length - 1; i >= 0; i--) { |
| 45 | + const turn = turns[i]; |
| 46 | + if (turn === undefined || turn.role !== "assistant") continue; |
| 47 | + if ( |
| 48 | + turn.content.some( |
| 49 | + (block) => block.type === "text" && block.text.length > 0, |
| 50 | + ) |
| 51 | + ) { |
| 52 | + return i; |
| 53 | + } |
| 54 | + } |
| 55 | + return -1; |
| 56 | +} |
| 57 | + |
| 58 | +// Tool results before the newest assistant text are already consumed. |
| 59 | +// When the model has not written that text yet, the newest result turn is |
| 60 | +// the unconsumed suffix and stays whole. |
| 61 | +function stubBoundary(turns: readonly ConversationTurn[]): number { |
| 62 | + const assistantText = newestAssistantTextIndex(turns); |
| 63 | + if (assistantText >= 0) return assistantText; |
| 64 | + for (let i = turns.length - 1; i >= 0; i--) { |
| 65 | + const turn = turns[i]; |
| 66 | + if (turn?.content.some((block) => block.type === "tool_result")) return i; |
| 67 | + } |
| 68 | + return turns.length; |
| 69 | +} |
| 70 | + |
| 71 | +function toolNames(turns: readonly ConversationTurn[]): Map<string, string> { |
| 72 | + const names = new Map<string, string>(); |
| 73 | + for (const turn of turns) { |
| 74 | + for (const block of turn.content) { |
| 75 | + if (block.type === "tool_call") names.set(block.id, block.name); |
| 76 | + } |
| 77 | + } |
| 78 | + return names; |
| 79 | +} |
| 80 | + |
| 81 | +function stubText(name: string | undefined): string { |
| 82 | + return name === undefined |
| 83 | + ? "[tool result omitted]" |
| 84 | + : `[${name} result omitted]`; |
| 85 | +} |
| 86 | + |
| 87 | +function stubToolResult( |
| 88 | + block: ToolResultBlock, |
| 89 | + names: ReadonlyMap<string, string>, |
| 90 | +): { block: ToolResultBlock; stubbed: boolean } { |
| 91 | + const text = stubText(names.get(block.callId)); |
| 92 | + if (JSON.stringify(block.content).length <= text.length) { |
| 93 | + return { block, stubbed: false }; |
| 94 | + } |
| 95 | + const next: ToolResultBlock = { |
| 96 | + type: "tool_result", |
| 97 | + callId: block.callId, |
| 98 | + content: [{ type: "text", text }], |
| 99 | + }; |
| 100 | + if (block.isError === true) next.isError = true; |
| 101 | + return { block: next, stubbed: true }; |
| 102 | +} |
| 103 | + |
| 104 | +function stubTurn( |
| 105 | + turn: ConversationTurn, |
| 106 | + names: ReadonlyMap<string, string>, |
| 107 | +): { turn: ConversationTurn; stubbed: number } { |
| 108 | + let stubbed = 0; |
| 109 | + let changed = false; |
| 110 | + const content = turn.content.map((block) => { |
| 111 | + if (block.type !== "tool_result") return block; |
| 112 | + const next = stubToolResult(block, names); |
| 113 | + if (!next.stubbed) return block; |
| 114 | + changed = true; |
| 115 | + stubbed += 1; |
| 116 | + return next.block; |
| 117 | + }); |
| 118 | + return { turn: changed ? { ...turn, content } : turn, stubbed }; |
| 119 | +} |
| 120 | + |
| 121 | +function shrinkPrompt(turns: ConversationTurn[]): { |
| 122 | + output: ConversationTurn[]; |
| 123 | + stubbed: number; |
| 124 | +} { |
| 125 | + const boundary = stubBoundary(turns); |
| 126 | + const names = toolNames(turns); |
| 127 | + let stubbed = 0; |
| 128 | + let changed = false; |
| 129 | + const output = turns.map((turn, index) => { |
| 130 | + if (index >= boundary) return turn; |
| 131 | + const next = stubTurn(turn, names); |
| 132 | + stubbed += next.stubbed; |
| 133 | + if (next.turn !== turn) changed = true; |
| 134 | + return next.turn; |
| 135 | + }); |
| 136 | + if (!changed) return { output: turns, stubbed: 0 }; |
| 137 | + return { output, stubbed }; |
| 138 | +} |
| 139 | + |
| 140 | +export function createAnthropicCachePromptTransform( |
| 141 | + deps: AnthropicCachePromptDeps, |
| 142 | +): ContextTransform { |
| 143 | + return { |
| 144 | + name: STRATEGY, |
| 145 | + version: "1", |
| 146 | + async apply(turns, _ctx) { |
| 147 | + const ttl = cacheTtlMsFor(deps.protocol()); |
| 148 | + if (ttl === undefined) return passthrough(turns, "non-anthropic"); |
| 149 | + const at = deps.cacheWriteAt(); |
| 150 | + if (at !== undefined && deps.nowMs() - at < ttl) { |
| 151 | + return passthrough(turns, "cache-warm"); |
| 152 | + } |
| 153 | + const shrunk = shrinkPrompt(turns); |
| 154 | + return { |
| 155 | + output: shrunk.output, |
| 156 | + record: { |
| 157 | + strategy: STRATEGY, |
| 158 | + version: "1", |
| 159 | + parameters: {}, |
| 160 | + reason: shrunk.stubbed > 0 ? "stubbed-tool-results" : "noop", |
| 161 | + decisions: { stubbed: shrunk.stubbed }, |
| 162 | + }, |
| 163 | + }; |
| 164 | + }, |
| 165 | + }; |
| 166 | +} |
0 commit comments