Repository navigation
fix(think): keep the prompt-cache prefix stable under truncation (#2200) #2356
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
threepointone
merged 2 commits into
feat/think-gateway-options
from
fix/think-stable-truncation-prefix
Sep 24, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@cloudflare/think": patch | ||
| --- | ||
|
|
||
| Keep the prompt-cache prefix stable across turns when older messages are truncated (#2200). The read-time truncation cutoff moved forward every turn, so each turn rewrote a message near the end of the cached prefix. Think now cuts at a multiple of 8 messages, keeping at least the 4 most recent messages whole and rewriting the prefix about once every 4 turns instead of every turn. Set the new `truncationStep` class field to change the step, or to `1` to restore the previous cut-every-turn behavior for models with a small context window. Media eviction and compaction were measured and already rewrite the prefix once per message or compaction. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,181 @@ | ||
| import type { LanguageModel, ToolSet, UIMessage } from "ai"; | ||
| import { tool } from "ai"; | ||
| import { z } from "zod"; | ||
| import { Think } from "../../think"; | ||
| import type { MediaEvictionConfig } from "../../think"; | ||
|
|
||
| /** | ||
| * Prompt-cache prefix measurement (#2200). Providers cache on a byte-identical | ||
| * prompt prefix, so for each turn this records how much of the first model | ||
| * request is a prefix of the request sent before it. | ||
| */ | ||
| export type PromptCacheScenario = { | ||
| turns: number; | ||
| /** Size of the `lookup` tool output called once per turn. 0 skips the tool. */ | ||
| toolOutputChars?: number; | ||
| /** Size of each user message. */ | ||
| userTextChars?: number; | ||
| /** Attach a data-URL image of this size to the first user message. */ | ||
| firstTurnMediaChars?: number; | ||
| /** Run a Think media eviction pass after each turn. */ | ||
| mediaEviction?: MediaEvictionConfig; | ||
| /** Compact every older message into one summary past this token estimate. */ | ||
| compactAfterTokens?: number; | ||
| /** Override `truncationStep`. */ | ||
| truncationStep?: number; | ||
| }; | ||
|
|
||
| export type PromptCacheTurn = { | ||
| turn: number; | ||
| messages: number; | ||
| requestChars: number; | ||
| /** Chars of this turn's first request shared with the previous request. */ | ||
| sharedPrefixChars: number; | ||
| /** Index of the first model message that differs, or null if none did. */ | ||
| firstChangedMessage: number | null; | ||
| compactionCalls?: number[]; | ||
| }; | ||
|
|
||
| const v3Usage = { | ||
| inputTokens: { total: 10, noCache: 10, cacheRead: 0, cacheWrite: 0 }, | ||
| outputTokens: { total: 5, text: 5, reasoning: 0 } | ||
| }; | ||
|
|
||
| export class ThinkPromptCacheTestAgent extends Think { | ||
| override mediaEviction: MediaEvictionConfig | boolean = false; | ||
| private _requests: unknown[][] = []; | ||
| private _toolOutputChars = 0; | ||
| private _compactionCalls: number[] = []; | ||
|
|
||
| override getModel(): LanguageModel { | ||
| const requests = this._requests; | ||
| const useTool = () => this._toolOutputChars > 0; | ||
| return { | ||
| specificationVersion: "v3", | ||
| provider: "test", | ||
| modelId: "prompt-cache-mock", | ||
| supportedUrls: {}, | ||
| doGenerate() { | ||
| throw new Error("doGenerate not implemented in mock"); | ||
| }, | ||
| doStream(options: { prompt: unknown[] }) { | ||
| requests.push(options.prompt); | ||
| const call = requests.length; | ||
| const last = options.prompt.at(-1) as { role?: string } | undefined; | ||
| const callTool = useTool() && last?.role === "user"; | ||
| const stream = new ReadableStream({ | ||
| start(controller) { | ||
| controller.enqueue({ type: "stream-start", warnings: [] }); | ||
| if (callTool) { | ||
| controller.enqueue({ | ||
| type: "tool-call", | ||
| toolCallId: `lookup-${call}`, | ||
| toolName: "lookup", | ||
| input: "{}" | ||
| }); | ||
| } else { | ||
| controller.enqueue({ type: "text-start", id: `t${call}` }); | ||
| controller.enqueue({ | ||
| type: "text-delta", | ||
| id: `t${call}`, | ||
| delta: "Here is the answer." | ||
| }); | ||
| controller.enqueue({ type: "text-end", id: `t${call}` }); | ||
| } | ||
| controller.enqueue({ | ||
| type: "finish", | ||
| finishReason: { | ||
| unified: callTool ? "tool-calls" : "stop", | ||
| raw: undefined | ||
| }, | ||
| usage: v3Usage | ||
| }); | ||
| controller.close(); | ||
| } | ||
| }); | ||
| return Promise.resolve({ stream }); | ||
| } | ||
| } as LanguageModel; | ||
| } | ||
|
|
||
| override getTools(): ToolSet { | ||
| return { | ||
| lookup: tool({ | ||
| description: "Look something up", | ||
| inputSchema: z.object({}), | ||
| execute: async () => "r".repeat(this._toolOutputChars) | ||
| }) | ||
| }; | ||
| } | ||
|
|
||
| async measurePromptCacheForTest( | ||
| scenario: PromptCacheScenario | ||
| ): Promise<PromptCacheTurn[]> { | ||
| this._toolOutputChars = scenario.toolOutputChars ?? 0; | ||
| if (scenario.mediaEviction) this.mediaEviction = scenario.mediaEviction; | ||
| if (scenario.truncationStep !== undefined) { | ||
| this.truncationStep = scenario.truncationStep; | ||
| } | ||
| if (scenario.compactAfterTokens !== undefined) { | ||
| this.session | ||
| .onCompaction(async (messages) => { | ||
| this._compactionCalls.push(messages.length); | ||
| const older = messages.slice(0, -4); | ||
| if (older.length < 2) return null; | ||
| return { | ||
| summary: `summary of ${older.length} messages`, | ||
| fromMessageId: older[0].id, | ||
| toMessageId: older[older.length - 1].id | ||
| }; | ||
| }) | ||
| .compactAfter(scenario.compactAfterTokens); | ||
| } | ||
|
|
||
| const report: PromptCacheTurn[] = []; | ||
| for (let turn = 0; turn < scenario.turns; turn++) { | ||
| const before = this._requests.length; | ||
| const text = `question ${turn} ${"q".repeat(scenario.userTextChars ?? 0)}`; | ||
| const parts: UIMessage["parts"] = [{ type: "text", text }]; | ||
| if (turn === 0 && scenario.firstTurnMediaChars) { | ||
| parts.push({ | ||
| type: "file", | ||
| mediaType: "image/png", | ||
| url: `data:image/png;base64,${"A".repeat(scenario.firstTurnMediaChars)}` | ||
| }); | ||
| } | ||
| await this.saveMessages([{ id: `u${turn}`, role: "user", parts }]); | ||
| if (scenario.mediaEviction) await this._evictAgedMediaBestEffort(); | ||
|
|
||
| const current = this._requests[before]; | ||
| const previous = this._requests[before - 1]; | ||
| const currentJson = current.map((message) => JSON.stringify(message)); | ||
| const serialized = JSON.stringify(current); | ||
| let sharedPrefixChars = 0; | ||
| let firstChangedMessage: number | null = null; | ||
| if (previous) { | ||
| const previousSerialized = JSON.stringify(previous); | ||
| while ( | ||
| sharedPrefixChars < serialized.length && | ||
| serialized[sharedPrefixChars] === | ||
| previousSerialized[sharedPrefixChars] | ||
| ) { | ||
| sharedPrefixChars++; | ||
| } | ||
| const previousJson = previous.map((message) => JSON.stringify(message)); | ||
| const index = previousJson.findIndex( | ||
| (message, i) => message !== currentJson[i] | ||
| ); | ||
| firstChangedMessage = index === -1 ? null : index; | ||
| } | ||
| report.push({ | ||
| turn, | ||
| messages: current.length, | ||
| requestChars: serialized.length, | ||
| sharedPrefixChars, | ||
| firstChangedMessage, | ||
| compactionCalls: [...this._compactionCalls] | ||
| }); | ||
| } | ||
| return report; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
| import { env } from "cloudflare:workers"; | ||
| import { getAgentByName } from "agents"; | ||
| import type { | ||
| PromptCacheScenario, | ||
| PromptCacheTurn | ||
| } from "./agents/prompt-cache"; | ||
|
|
||
| async function measure( | ||
| name: string, | ||
| scenario: PromptCacheScenario | ||
| ): Promise<PromptCacheTurn[]> { | ||
| const agent = await getAgentByName(env.ThinkPromptCacheTestAgent, name); | ||
| return agent.measurePromptCacheForTest(scenario); | ||
| } | ||
|
|
||
| /** Turns whose first request did not extend the request sent before it. */ | ||
| function prefixBreaks(report: PromptCacheTurn[]): number[] { | ||
| return report | ||
| .filter((turn) => turn.firstChangedMessage !== null) | ||
| .map((turn) => turn.turn); | ||
| } | ||
|
|
||
| // Providers cache on a byte-identical prompt prefix. Each turn's first model | ||
| // request should extend the previous request unless a context-reduction | ||
| // mechanism rewrote history, and those rewrites should be one-shot rather | ||
| // than happen every turn. | ||
| describe("prompt-cache prefix stability (#2200)", () => { | ||
| it("extends the previous request every turn with no reduction", async () => { | ||
| const report = await measure("prefix-baseline", { turns: 8 }); | ||
| expect(prefixBreaks(report)).toEqual([]); | ||
| }); | ||
|
|
||
| it("rewrites truncated tool outputs once per step, not every turn", async () => { | ||
| const report = await measure("prefix-tool-output", { | ||
| turns: 16, | ||
| toolOutputChars: 4000 | ||
| }); | ||
| expect(prefixBreaks(report)).toEqual([6, 10, 14]); | ||
| }); | ||
|
|
||
| it("cuts every turn when truncationStep is 1", async () => { | ||
| const report = await measure("prefix-step-one", { | ||
| turns: 8, | ||
| toolOutputChars: 4000, | ||
| truncationStep: 1 | ||
| }); | ||
| expect(prefixBreaks(report)).toEqual([3, 4, 5, 6, 7]); | ||
| expect(report.at(-1)!.requestChars).toBeLessThan( | ||
| ( | ||
| await measure("prefix-step-default", { | ||
| turns: 8, | ||
| toolOutputChars: 4000 | ||
| }) | ||
| ).at(-1)!.requestChars | ||
| ); | ||
| }); | ||
|
|
||
| it("rewrites truncated long text once per step, not every turn", async () => { | ||
| const report = await measure("prefix-long-text", { | ||
| turns: 16, | ||
| userTextChars: 12_000 | ||
| }); | ||
| expect(prefixBreaks(report)).toEqual([6, 10, 14]); | ||
| }); | ||
|
|
||
| it("rewrites the prefix once when media is evicted", async () => { | ||
| const report = await measure("prefix-media", { | ||
| turns: 8, | ||
| firstTurnMediaChars: 50_000, | ||
| mediaEviction: { keepRecentMessages: 2, minPartBytes: 10_000 } | ||
| }); | ||
| expect(prefixBreaks(report)).toHaveLength(1); | ||
| expect(report.at(-1)!.requestChars).toBeLessThan(10_000); | ||
|
threepointone marked this conversation as resolved.
|
||
| }); | ||
|
|
||
| it("rewrites the prefix once per compaction", async () => { | ||
| const report = await measure("prefix-compaction", { | ||
| turns: 16, | ||
| userTextChars: 400, | ||
| compactAfterTokens: 1000 | ||
| }); | ||
| const compactedAt = report | ||
| .filter( | ||
| (turn, i) => | ||
| (turn.compactionCalls?.length ?? 0) > | ||
| (report[i - 1]?.compactionCalls?.length ?? 0) | ||
| ) | ||
| .map((turn) => turn.turn); | ||
| expect(compactedAt.length).toBeGreaterThan(0); | ||
| expect(prefixBreaks(report)).toEqual(compactedAt); | ||
| }); | ||
| }); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.