From eaa6199f22b4120b2b1a0af78576a1c1084af983 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Sat, 9 May 2026 14:56:22 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20Tool=20Search=20?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E5=A4=B1=E6=95=88=20=E2=80=94=20deferred=20?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E4=B8=8D=E5=86=8D=E5=8A=A8=E6=80=81=E6=B3=A8?= =?UTF-8?q?=E5=85=A5=20tools=20=E6=95=B0=E7=BB=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除 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] --- src/services/api/claude.ts | 88 +++++++++++++++++++------------- src/services/api/openai/index.ts | 57 ++++++++++++++++++--- 2 files changed, 102 insertions(+), 43 deletions(-) diff --git a/src/services/api/claude.ts b/src/services/api/claude.ts index 598bf3ce2..2bb078918 100644 --- a/src/services/api/claude.ts +++ b/src/services/api/claude.ts @@ -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( } const obj = contentBlock as unknown as Record - 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 | undefined = undefined - let streamRequestId: string | null | undefined = undefined - let clientRequestId: string | undefined = undefined + let stream: Stream | 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, diff --git a/src/services/api/openai/index.ts b/src/services/api/openai/index.ts index fe6e9474b..b6f24fecc 100644 --- a/src/services/api/openai/index.ts +++ b/src/services/api/openai/index.ts @@ -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() + 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)