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:
parent
e1e2378795
commit
eaa6199f22
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user