docs/design/prompt-cache/global-tool-schema-stable-sort.md
Qwen Code already supports cache_control in the Anthropic and DashScope
request conversion layers. When a provider supports prompt caching, a stable
request prefix can be cached and reused, reducing repeated input-token cost and
lowering time to first token.
The main prefix currently has three parts:
ToolRegistry.getFunctionDeclarations().The tools schema is often large and appears near the front of the provider cache prefix. If the serialized bytes of the tools array change, the following system and messages prefix can also lose reuse.
Today GeminiClient.setTools() directly uses the return value of
ToolRegistry.getFunctionDeclarations(), and getFunctionDeclarations()
iterates tools in Map insertion order. Built-in tool registration order is
usually stable, but progressive MCP discovery, ToolSearch reveals, MCP
reconnects, and external tool registration can all cause the same tool set to be
serialized in different orders. That creates unnecessary prompt cache misses.
Implement global stable sorting for tool schemas: functionDeclarations sent to
model requests must have a stable order for the same tool set, independent of
registration completion order.
This design only addresses cache misses where the tool set is identical but the order differs. Adding tools, removing tools, or changing schema content still changes the prefix; those are legitimate cache misses.
This design does not include:
cache_control policy changes.flowchart LR
A[ToolRegistry tools Map] --> B[getFunctionDeclarations]
B --> C[GeminiClient.setTools]
C --> D[GenerateContent config.tools]
E[systemInstruction] --> F[Provider converter]
G[history/messages] --> F
D --> F
F --> H[cache_control markers]
H --> I[Provider prompt cache]
Progressive MCP discovery is the most common source of order churn:
sequenceDiagram
participant C as Config
participant M as McpClientManager
participant R as ToolRegistry
participant G as GeminiClient
participant P as Provider
C->>M: discoverAllMcpToolsIncremental()
M->>M: Discover multiple MCP servers concurrently
M->>R: registerTool(mcp tool)
M-->>C: mcp-client-update
C->>G: setTools()
G->>R: getFunctionDeclarations()
G->>P: tools + system + messages
If two MCP servers eventually become available but settle in different orders, the current tools block can differ:
Run 1:
[
read_file,
shell,
mcp__filesystem__read_tree,
mcp__github__search_issues
]
Run 2:
[
read_file,
shell,
mcp__github__search_issues,
mcp__filesystem__read_tree
]
From a model-capability perspective, both runs expose the same tool set. From a prompt-cache perspective, they are different tools prefixes.
After sorting, the same set stabilizes to:
[
mcp__filesystem__read_tree,
mcp__github__search_issues,
read_file,
shell
]
Prompt cache lets the provider reuse KV/cache computation for a stable prefix. For long tool lists, long system prompts, and long history prefixes, a cache hit usually has two benefits:
Before a hit:
request bytes changed
-> tools/system/messages prefix cannot be reused
-> cache_read_input_tokens is low or 0
-> the full prefix is counted again as input/cache creation
-> TTFT is higher
After a hit:
stable prefix bytes unchanged
-> tools/system/messages prefix is reused from provider cache
-> cache_read_input_tokens increases
-> only the new tail content is counted as input/cache creation
-> TTFT is lower
This design improves hit probability by stabilizing tools array order, especially for registration-order churn caused by progressive MCP discovery and ToolSearch reveals.
Sorting belongs in ToolRegistry.getFunctionDeclarations() because it is the
single generation point for current API tool declarations. Do not sort in the
provider converter, because other declaration readers would remain unstable. Do
not sort only in GeminiClient.setTools(), because diagnostics, context
estimation, and tests could still observe unsorted declarations.
Sorting rules:
shouldDefer && !alwaysLoad && !revealedDeferred.{ includeDeferred: true } includes deferred tools.alwaysLoad tools are always visible.tool.schema.name ?? tool.name as the primary sort key.tool.displayName as the tie-breaker.tool.schema values.Pseudo-code:
getFunctionDeclarations(options?: { includeDeferred?: boolean }) {
const includeDeferred = options?.includeDeferred === true;
return Array.from(this.tools.values())
.filter((tool) => {
if (
!includeDeferred &&
tool.shouldDefer &&
!tool.alwaysLoad &&
!this.revealedDeferred.has(tool.name)
) {
return false;
}
return true;
})
.sort(compareToolsByDeclarationName)
.map((tool) => tool.schema);
}
Keep the comparison function local and simple. Do not add configuration:
function compareToolsByDeclarationName(
a: AnyDeclarativeTool,
b: AnyDeclarativeTool,
) {
const aName = a.schema.name ?? a.name;
const bName = b.schema.name ?? b.name;
const byName = aName.localeCompare(bName);
if (byName !== 0) return byName;
return a.displayName.localeCompare(b.displayName);
}
Do not preserve registration order as implicit ranking. Tool order should not express model preference; the model should choose tools based on name, description, schema, and context.
Add or update tests in packages/core/src/tools/tool-registry.test.ts.
Registration order:
zeta, alpha, middle
Assertion:
getFunctionDeclarations().map(name) === [alpha, middle, zeta]
Register:
visible-z
hidden-a (shouldDefer)
visible-a
Default assertion:
[visible-a, visible-z]
Use the same tools as above and call:
getFunctionDeclarations({ includeDeferred: true });
Assertion:
[hidden-a, visible-a, visible-z]
Register:
visible-m
hidden-a (shouldDefer)
visible-z
Execute:
toolRegistry.revealDeferredTool('hidden-a');
Assertion:
[hidden-a, visible-m, visible-z]
Register:
z (shouldDefer, alwaysLoad)
a
Default assertion:
[a, z]
Create two ToolRegistry instances:
registryA registration order:
mcp__github__search_issues
mcp__filesystem__read_tree
registryB registration order:
mcp__filesystem__read_tree
mcp__github__search_issues
Assertion:
registryA.getFunctionDeclarations().map(name)
=== registryB.getFunctionDeclarations().map(name)
Existing tests that depend on registration order should be updated to depend on
the sorted order instead. For example, a deferred-filtering test that only
asserts ['visible'] can remain as-is; if it registers multiple visible tools
in the future, it should assert the sorted array.
Recommended verification commands:
cd packages/core && npx vitest run src/tools/tool-registry.test.ts
cd packages/core && npx vitest run src/tools/tool-search.test.ts
cd packages/core && npx vitest run src/core/client.test.ts
npm run build && npm run typecheck
After global sorting lands, the next step should be lightweight prompt cache break detection to validate the sorting benefit and locate remaining cache misses.
Implement it in two phases:
cache_read_input_tokens.cache_creation_input_tokens.When cache read drops significantly from the previous turn, emit a debug log or telemetry event:
prompt_cache_break:
reason: tools_order_changed | tools_schema_changed | system_changed |
cache_control_changed | model_changed | likely_provider_ttl_or_eviction
previousCacheReadTokens
currentCacheReadTokens
changedToolNames
The first version should observe only and must not change request behavior. Its goal is to answer two questions:
cache_control, or provider TTL/eviction?