fix: 修复 Tool Search 缓存失效 — deferred 工具不再动态注入 tools 数组

移除 deferred 工具的 "discover then include" 逻辑,让 tools 数组在整个会话中
保持稳定(只有 core tools + ToolSearch + ExecuteExtraTool),避免每次发现新
工具时 tools JSON 变化导致 prompt cache 失效。

同时强化工具优先级引导:core tools 优先直接调用,ToolSearch/ExecuteExtraTool
仅作为发现和调用 deferred 工具的最后手段。当模型搜索已加载的 core tool 时,
ToolSearch 返回明确的拒绝提示。

Co-Authored-By: glm-5.1[1m] <zai-org@claude-code-best.win>
This commit is contained in:
claude-code-best 2026-05-09 14:56:22 +08:00 committed by James Feng
parent e1e2378795
commit eaa6199f22
2 changed files with 102 additions and 43 deletions

View File

@ -185,7 +185,6 @@ import {
type ThinkingConfig,
} from 'src/utils/thinking.js'
import {
extractDiscoveredToolNames,
isDeferredToolsDeltaEnabled,
isToolSearchEnabled,
} from 'src/utils/toolSearch.js'
@ -446,7 +445,7 @@ function configureEffortParams(
betas.push(EFFORT_BETA_HEADER)
} else if (typeof effortValue === 'string') {
// Send string effort level as is
outputConfig.effort = effortValue as "high" | "medium" | "low" | "max"
outputConfig.effort = effortValue as 'high' | 'medium' | 'low' | 'max'
betas.push(EFFORT_BETA_HEADER)
} else if (process.env.USER_TYPE === 'ant') {
// Numeric effort override - ant-only (uses anthropic_internal)
@ -640,7 +639,8 @@ export function userMessageToMessageParam(
role: 'user',
content: (Array.isArray(message.message!.content)
? [...message.message!.content]
: message.message!.content) as import('@anthropic-ai/sdk/resources/beta/messages/messages.js').BetaContentBlockParam[],
: message.message!
.content) as import('@anthropic-ai/sdk/resources/beta/messages/messages.js').BetaContentBlockParam[],
}
}
@ -691,7 +691,9 @@ export function assistantMessageToMessageParam(
content:
typeof message.message!.content === 'string'
? message.message!.content
: message.message!.content!.map(stripGeminiProviderMetadata) as BetaContentBlockParam[],
: (message.message!.content!.map(
stripGeminiProviderMetadata,
) as BetaContentBlockParam[]),
}
}
@ -706,10 +708,8 @@ function stripGeminiProviderMetadata<T extends BetaContentBlockParam | string>(
}
const obj = contentBlock as unknown as Record<string, unknown>
const {
_geminiThoughtSignature: _unusedGeminiThoughtSignature,
...rest
} = obj
const { _geminiThoughtSignature: _unusedGeminiThoughtSignature, ...rest } =
obj
return rest as unknown as T
}
@ -1189,23 +1189,21 @@ async function* queryModel(
useToolSearch = false
}
// Filter out ToolSearchTool if tool search is not enabled for this model
// ToolSearchTool returns tool_reference blocks which unsupported models can't handle
// Dynamic tool loading: filter deferred tools that haven't been discovered yet
let filteredTools: Tools
if (useToolSearch) {
// Dynamic tool loading: Only include deferred tools that have been discovered
// via tool_reference blocks in the message history. This eliminates the need
// to predeclare all deferred tools upfront and removes limits on tool quantity.
const discoveredToolNames = extractDiscoveredToolNames(messages)
// Never include deferred tools in the API tools array — they are invoked
// via ExecuteExtraTool which looks them up from the global tool registry
// at runtime. Keeping the tools array stable preserves the prompt cache
// across turns (discovered tools no longer bloat the tools JSON).
filteredTools = tools.filter(tool => {
// Always include non-deferred tools
// Always include non-deferred tools (core tools)
if (!deferredToolNames.has(tool.name)) return true
// Always include ToolSearchTool (so it can discover more tools)
if (toolMatchesName(tool, TOOL_SEARCH_TOOL_NAME)) return true
// Only include deferred tools that have been discovered
return discoveredToolNames.has(tool.name)
// All other deferred tools are excluded — use ExecuteExtraTool instead
return false
})
} else {
filteredTools = tools.filter(
@ -1288,11 +1286,8 @@ async function* queryModel(
)
if (useToolSearch) {
const includedDeferredTools = count(filteredTools, t =>
deferredToolNames.has(t.name),
)
logForDebugging(
`Dynamic tool loading: ${includedDeferredTools}/${deferredToolNames.size} deferred tools included`,
`Dynamic tool loading: 0/${deferredToolNames.size} deferred tools in API tools array (all via ExecuteExtraTool)`,
)
}
@ -1361,7 +1356,13 @@ async function* queryModel(
// media stripping) but before Anthropic-specific logic (betas, thinking, caching).
if (getAPIProvider() === 'openai') {
const { queryModelOpenAI } = await import('./openai/index.js')
yield* queryModelOpenAI(messagesForAPI, systemPrompt, filteredTools, signal, options)
yield* queryModelOpenAI(
messagesForAPI,
systemPrompt,
filteredTools,
signal,
options,
)
return
}
@ -1380,7 +1381,13 @@ async function* queryModel(
if (getAPIProvider() === 'grok') {
const { queryModelGrok } = await import('./grok/index.js')
yield* queryModelGrok(messagesForAPI, systemPrompt, filteredTools, signal, options)
yield* queryModelGrok(
messagesForAPI,
systemPrompt,
filteredTools,
signal,
options,
)
return
}
@ -1576,11 +1583,11 @@ async function* queryModel(
let start = Date.now()
let attemptNumber = 0
const attemptStartTimes: number[] = []
let stream: Stream<BetaRawMessageStreamEvent> | undefined = undefined
let streamRequestId: string | null | undefined = undefined
let clientRequestId: string | undefined = undefined
let stream: Stream<BetaRawMessageStreamEvent> | undefined
let streamRequestId: string | null | undefined
let clientRequestId: string | undefined
// eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins -- Response is available in Node 18+ and is used by the SDK
let streamResponse: Response | undefined = undefined
let streamResponse: Response | undefined
// Release all stream resources to prevent native memory leaks.
// The Response object holds native TLS/socket buffers that live outside the
@ -1666,7 +1673,7 @@ async function* queryModel(
const hasThinking =
thinkingConfig.type !== 'disabled' &&
!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_THINKING)
let thinking: BetaMessageStreamParams['thinking'] | undefined = undefined
let thinking: BetaMessageStreamParams['thinking'] | undefined
// IMPORTANT: Do not change the adaptive-vs-budget thinking selection below
// without notifying the model launch DRI and research. This is a sensitive
@ -1837,7 +1844,7 @@ async function* queryModel(
const newMessages: AssistantMessage[] = []
let ttftMs = 0
let partialMessage: BetaMessage | undefined = undefined
let partialMessage: BetaMessage | undefined
const contentBlocks: (BetaContentBlock | ConnectorTextBlock)[] = []
// Accumulate streaming deltas in arrays to avoid O(n²) string concatenation.
// Joined and assigned to contentBlock fields at content_block_stop.
@ -1848,8 +1855,8 @@ async function* queryModel(
let didFallBackToNonStreaming = false
let fallbackMessage: AssistantMessage | undefined
let maxOutputTokens = 0
let responseHeaders: globalThis.Headers | undefined = undefined
let research: unknown = undefined
let responseHeaders: globalThis.Headers | undefined
let research: unknown
let isFastModeRequest = isFastMode // Keep separate state as it may change if falling back
let isAdvisorInProgress = false
@ -2360,7 +2367,10 @@ async function* queryModel(
}
// Update cost
const costUSDForPart = calculateUSDCost(resolvedModel, usage as unknown as BetaUsage)
const costUSDForPart = calculateUSDCost(
resolvedModel,
usage as unknown as BetaUsage,
)
costUSD += addToTotalSessionCost(
costUSDForPart,
usage as unknown as BetaUsage,
@ -2930,10 +2940,14 @@ async function* queryModel(
// message_delta handler before any yield. Fallback pushes to newMessages
// then yields, so tracking must be here to survive .return() at the yield.
if (fallbackMessage) {
const fallbackUsage = fallbackMessage.message.usage as BetaMessageDeltaUsage
const fallbackUsage = fallbackMessage.message
.usage as BetaMessageDeltaUsage
usage = updateUsage(EMPTY_USAGE, fallbackUsage)
stopReason = fallbackMessage.message.stop_reason as BetaStopReason
const fallbackCost = calculateUSDCost(resolvedModel, fallbackUsage as unknown as BetaUsage)
const fallbackCost = calculateUSDCost(
resolvedModel,
fallbackUsage as unknown as BetaUsage,
)
costUSD += addToTotalSessionCost(
fallbackCost,
fallbackUsage as unknown as BetaUsage,
@ -2989,7 +3003,9 @@ async function* queryModel(
void options.getToolPermissionContext().then(permissionContext => {
logAPISuccessAndDuration({
model:
(newMessages[0]?.message.model as string | undefined) ?? partialMessage?.model ?? options.model,
(newMessages[0]?.message.model as string | undefined) ??
partialMessage?.model ??
options.model,
preNormalizedModel: options.model,
usage,
start,

View File

@ -17,7 +17,10 @@ import { adaptOpenAIStreamToAnthropic } from './streamAdapter.js'
import { resolveOpenAIModel } from './modelMapping.js'
import { normalizeMessagesForAPI } from '../../../utils/messages.js'
import { toolToAPISchema } from '../../../utils/api.js'
import { getEmptyToolPermissionContext } from '../../../Tool.js'
import {
getEmptyToolPermissionContext,
toolMatchesName,
} from '../../../Tool.js'
import { logForDebugging } from '../../../utils/debug.js'
import { addToTotalSessionCost } from '../../../cost-tracker.js'
import { calculateUSDCost } from '../../../utils/modelCost.js'
@ -27,6 +30,11 @@ import {
createAssistantAPIErrorMessage,
normalizeContentFromAPI,
} from '../../../utils/messages.js'
import { isToolSearchEnabled } from '../../../utils/toolSearch.js'
import {
isDeferredTool,
TOOL_SEARCH_TOOL_NAME,
} from '../../../tools/ToolSearchTool/prompt.js'
/**
* OpenAI-compatible query path. Converts Anthropic-format messages/tools to
@ -51,15 +59,50 @@ export async function* queryModelOpenAI(
// 2. Normalize messages using shared preprocessing
const messagesForAPI = normalizeMessagesForAPI(messages, tools)
// 3. Build tool schemas
// 3. Check if tool search is enabled (similar to Anthropic path)
const useToolSearch = await isToolSearchEnabled(
options.model,
tools,
options.getToolPermissionContext ||
(async () => getEmptyToolPermissionContext()),
options.agents || [],
options.querySource,
)
// 4. Build deferred tools set (similar to Anthropic path)
const deferredToolNames = new Set<string>()
if (useToolSearch) {
for (const tool of tools) {
if (isDeferredTool(tool)) deferredToolNames.add(tool.name)
}
}
// 5. Filter tools (similar to Anthropic path)
// Never include deferred tools in the API tools array — they are invoked
// via ExecuteExtraTool which looks them up from the global tool registry
// at runtime. Keeping the tools array stable preserves the prompt cache.
let filteredTools = tools
if (useToolSearch && deferredToolNames.size > 0) {
filteredTools = tools.filter(tool => {
// Always include non-deferred tools
if (!deferredToolNames.has(tool.name)) return true
// Always include ToolSearchTool (so it can discover more tools)
if (toolMatchesName(tool, TOOL_SEARCH_TOOL_NAME)) return true
// All other deferred tools are excluded — use ExecuteExtraTool instead
return false
})
}
// 6. Build tool schemas
const toolSchemas = await Promise.all(
tools.map(tool =>
filteredTools.map(tool =>
toolToAPISchema(tool, {
getToolPermissionContext: options.getToolPermissionContext,
tools,
agents: options.agents,
allowedAgentTypes: options.allowedAgentTypes,
model: options.model,
deferLoading: useToolSearch && deferredToolNames.has(tool.name),
}),
),
)
@ -73,7 +116,7 @@ export async function* queryModelOpenAI(
},
)
// 4. Convert messages and tools to OpenAI format
// 7. Convert messages and tools to OpenAI format
const openaiMessages = anthropicMessagesToOpenAI(
messagesForAPI,
systemPrompt,
@ -81,7 +124,7 @@ export async function* queryModelOpenAI(
const openaiTools = anthropicToolsToOpenAI(standardTools)
const openaiToolChoice = anthropicToolChoiceToOpenAI(options.toolChoice)
// 5. Get client and make streaming request
// 8. Get client and make streaming request
const client = getOpenAIClient({
maxRetries: 0,
fetchOverride: options.fetchOverride,
@ -92,7 +135,7 @@ export async function* queryModelOpenAI(
`[OpenAI] Calling model=${openaiModel}, messages=${openaiMessages.length}, tools=${openaiTools.length}`,
)
// 6. Call OpenAI API with streaming
// 9. Call OpenAI API with streaming
const stream = await client.chat.completions.create(
{
model: openaiModel,
@ -112,7 +155,7 @@ export async function* queryModelOpenAI(
},
)
// 7. Convert OpenAI stream to Anthropic events, then process into
// 10. Convert OpenAI stream to Anthropic events, then process into
// AssistantMessage + StreamEvent (matching the Anthropic path behavior)
const adaptedStream = adaptOpenAIStreamToAnthropic(stream, openaiModel)