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

View File

@ -17,7 +17,10 @@ import { adaptOpenAIStreamToAnthropic } from './streamAdapter.js'
import { resolveOpenAIModel } from './modelMapping.js' import { resolveOpenAIModel } from './modelMapping.js'
import { normalizeMessagesForAPI } from '../../../utils/messages.js' import { normalizeMessagesForAPI } from '../../../utils/messages.js'
import { toolToAPISchema } from '../../../utils/api.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 { logForDebugging } from '../../../utils/debug.js'
import { addToTotalSessionCost } from '../../../cost-tracker.js' import { addToTotalSessionCost } from '../../../cost-tracker.js'
import { calculateUSDCost } from '../../../utils/modelCost.js' import { calculateUSDCost } from '../../../utils/modelCost.js'
@ -27,6 +30,11 @@ import {
createAssistantAPIErrorMessage, createAssistantAPIErrorMessage,
normalizeContentFromAPI, normalizeContentFromAPI,
} from '../../../utils/messages.js' } 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 * OpenAI-compatible query path. Converts Anthropic-format messages/tools to
@ -51,15 +59,50 @@ export async function* queryModelOpenAI(
// 2. Normalize messages using shared preprocessing // 2. Normalize messages using shared preprocessing
const messagesForAPI = normalizeMessagesForAPI(messages, tools) 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( const toolSchemas = await Promise.all(
tools.map(tool => filteredTools.map(tool =>
toolToAPISchema(tool, { toolToAPISchema(tool, {
getToolPermissionContext: options.getToolPermissionContext, getToolPermissionContext: options.getToolPermissionContext,
tools, tools,
agents: options.agents, agents: options.agents,
allowedAgentTypes: options.allowedAgentTypes, allowedAgentTypes: options.allowedAgentTypes,
model: options.model, 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( const openaiMessages = anthropicMessagesToOpenAI(
messagesForAPI, messagesForAPI,
systemPrompt, systemPrompt,
@ -81,7 +124,7 @@ export async function* queryModelOpenAI(
const openaiTools = anthropicToolsToOpenAI(standardTools) const openaiTools = anthropicToolsToOpenAI(standardTools)
const openaiToolChoice = anthropicToolChoiceToOpenAI(options.toolChoice) const openaiToolChoice = anthropicToolChoiceToOpenAI(options.toolChoice)
// 5. Get client and make streaming request // 8. Get client and make streaming request
const client = getOpenAIClient({ const client = getOpenAIClient({
maxRetries: 0, maxRetries: 0,
fetchOverride: options.fetchOverride, fetchOverride: options.fetchOverride,
@ -92,7 +135,7 @@ export async function* queryModelOpenAI(
`[OpenAI] Calling model=${openaiModel}, messages=${openaiMessages.length}, tools=${openaiTools.length}`, `[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( const stream = await client.chat.completions.create(
{ {
model: openaiModel, 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) // AssistantMessage + StreamEvent (matching the Anthropic path behavior)
const adaptedStream = adaptOpenAIStreamToAnthropic(stream, openaiModel) const adaptedStream = adaptOpenAIStreamToAnthropic(stream, openaiModel)