docs: 添加 ToolSearch 设计指南 + 禁用 turn-zero 工具推荐弹窗

- 新增 docs/design/tool-search-design-guide.md,涵盖架构、搜索算法、执行管道、演进历史
- 禁用 getTurnZeroSearchExtraToolsPrefetch,消除用户输入时的频繁弹窗
- inter-turn 发现机制保持不变

Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
This commit is contained in:
claude-code-best
2026-05-09 16:45:56 +08:00
parent bd2253846f
commit 2cf18c4c49
61 changed files with 753 additions and 423 deletions

View File

@@ -162,7 +162,7 @@ import {
shouldUseGlobalCacheScope,
} from 'src/utils/betas.js'
import { CLAUDE_IN_CHROME_MCP_SERVER_NAME } from 'src/utils/claudeInChrome/common.js'
import { CHROME_TOOL_SEARCH_INSTRUCTIONS } from 'src/utils/claudeInChrome/prompt.js'
import { CHROME_SEARCH_EXTRA_TOOLS_INSTRUCTIONS } from 'src/utils/claudeInChrome/prompt.js'
import { getMaxThinkingTokensForModel } from 'src/utils/context.js'
import { logForDebugging } from 'src/utils/debug.js'
import { logForDiagnosticsNoPII } from 'src/utils/diagLogs.js'
@@ -185,15 +185,15 @@ import {
} from 'src/utils/thinking.js'
import {
isDeferredToolsDeltaEnabled,
isToolSearchEnabled,
} from 'src/utils/toolSearch.js'
isSearchExtraToolsEnabled,
} from 'src/utils/searchExtraTools.js'
import { API_MAX_MEDIA_PER_REQUEST } from '../../constants/apiLimits.js'
import { ADVISOR_BETA_HEADER } from '../../constants/betas.js'
import {
formatDeferredToolLine,
isDeferredTool,
TOOL_SEARCH_TOOL_NAME,
} from '@claude-code-best/builtin-tools/tools/ToolSearchTool/prompt.js'
SEARCH_EXTRA_TOOLS_TOOL_NAME,
} from '@claude-code-best/builtin-tools/tools/SearchExtraToolsTool/prompt.js'
import { count } from '../../utils/array.js'
import { insertBlockAfterToolResults } from '../../utils/contentArray.js'
import { validateBoundedIntEnvVar } from '../../utils/envValidation.js'
@@ -1155,7 +1155,7 @@ async function* queryModel(
// Check if tool search is enabled (checks mode, model support, and threshold for auto mode)
// This is async because it may need to calculate MCP tool description sizes for TstAuto mode
let useToolSearch = await isToolSearchEnabled(
let useSearchExtraTools = await isSearchExtraToolsEnabled(
options.model,
tools,
options.getToolPermissionContext,
@@ -1165,7 +1165,7 @@ async function* queryModel(
// Precompute once — isDeferredTool does 2 GrowthBook lookups per call
const deferredToolNames = new Set<string>()
if (useToolSearch) {
if (useSearchExtraTools) {
for (const t of tools) {
if (isDeferredTool(t)) deferredToolNames.add(t.name)
}
@@ -1173,25 +1173,25 @@ async function* queryModel(
// Even if tool search mode is enabled, skip if there are no deferred tools
// AND no MCP servers are still connecting. When servers are pending, keep
// ToolSearch available so the model can discover tools after they connect.
// SearchExtraTools available so the model can discover tools after they connect.
if (
useToolSearch &&
useSearchExtraTools &&
deferredToolNames.size === 0 &&
!options.hasPendingMcpServers
) {
logForDebugging(
'Tool search disabled: no deferred tools available to search',
)
useToolSearch = false
useSearchExtraTools = false
}
// Dynamic tool loading: filter deferred tools that haven't been discovered yet
let filteredTools: Tools
// Deferred tools that haven't been discovered are filtered out from the API
// request — their schemas are only included after ToolSearch discovers them.
// request — their schemas are only included after SearchExtraTools discovers them.
if (useToolSearch) {
if (useSearchExtraTools) {
// 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
@@ -1199,19 +1199,19 @@ async function* queryModel(
filteredTools = tools.filter(tool => {
// 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
// Always include SearchExtraToolsTool (so it can discover more tools)
if (toolMatchesName(tool, SEARCH_EXTRA_TOOLS_TOOL_NAME)) return true
// All other deferred tools are excluded — use ExecuteExtraTool instead
return false
})
} else {
filteredTools = tools.filter(
t => !toolMatchesName(t, TOOL_SEARCH_TOOL_NAME),
t => !toolMatchesName(t, SEARCH_EXTRA_TOOLS_TOOL_NAME),
)
}
// Tool search beta header and defer_loading removed — unified self-built
// tool search via ToolSearchTool + ExecuteExtraTool for all providers.
// tool search via SearchExtraToolsTool + ExecuteExtraTool for all providers.
// No longer relies on API-side tool_reference or defer_loading features.
// Determine if cached microcompact is enabled for this model.
@@ -1264,7 +1264,7 @@ async function* queryModel(
// Build tool schemas — no defer_loading since we use self-built tool search
// Note: We pass the full `tools` list (not filteredTools) to toolToAPISchema so that
// ToolSearchTool's prompt can list ALL available MCP tools. The filtering only affects
// SearchExtraToolsTool's prompt can list ALL available MCP tools. The filtering only affects
// which tools are actually sent to the API, not what the model sees in tool descriptions.
const toolSchemas = await Promise.all(
filteredTools.map(tool =>
@@ -1278,7 +1278,7 @@ async function* queryModel(
),
)
if (useToolSearch) {
if (useSearchExtraTools) {
logForDebugging(
`Dynamic tool loading: 0/${deferredToolNames.size} deferred tools in API tools array (all via ExecuteExtraTool)`,
)
@@ -1300,17 +1300,17 @@ async function* queryModel(
// selected model doesn't support tool search.
//
// Why is this needed in addition to normalizeMessagesForAPI?
// - normalizeMessagesForAPI uses isToolSearchEnabledNoModelCheck() because it's
// - normalizeMessagesForAPI uses isSearchExtraToolsEnabledNoModelCheck() because it's
// called from ~20 places (analytics, feedback, sharing, etc.), many of which
// don't have model context. Adding model to its signature would be a large refactor.
// - This post-processing uses the model-aware isToolSearchEnabled() check
// - This post-processing uses the model-aware isSearchExtraToolsEnabled() check
// - This handles mid-conversation model switching (e.g., Sonnet → Haiku) where
// stale tool-search fields from the previous model would cause 400 errors
//
// Note: For assistant messages, normalizeMessagesForAPI already normalized the
// tool inputs, so stripCallerFieldFromAssistantMessage only needs to remove the
// 'caller' field (not re-normalize inputs).
if (!useToolSearch) {
if (!useSearchExtraTools) {
messagesForAPI = messagesForAPI.map(msg => {
switch (msg.type) {
case 'user':
@@ -1350,7 +1350,7 @@ async function* queryModel(
if (getAPIProvider() === 'openai') {
const { queryModelOpenAI } = await import('./openai/index.js')
// OpenAI emulates Anthropic's dynamic tool loading client-side. It needs
// the full tool pool so ToolSearchTool can search deferred MCP tools that
// the full tool pool so SearchExtraToolsTool can search deferred MCP tools that
// were intentionally filtered out of the initial API tool list above.
yield* queryModelOpenAI(
messagesForAPI,
@@ -1400,7 +1400,7 @@ async function* queryModel(
// When the delta attachment is enabled, deferred tools are announced
// via persisted deferred_tools_delta attachments instead of this
// ephemeral prepend (which busts cache whenever the pool changes).
if (useToolSearch && !isDeferredToolsDeltaEnabled()) {
if (useSearchExtraTools && !isDeferredToolsDeltaEnabled()) {
const deferredToolList = tools
.filter(t => deferredToolNames.has(t.name))
.map(formatDeferredToolLine)
@@ -1409,7 +1409,7 @@ async function* queryModel(
if (deferredToolList) {
messagesForAPI = [
createUserMessage({
content: `<available-deferred-tools>\n${deferredToolList}\n</available-deferred-tools>`,
content: `<available-deferred-tools>\n${deferredToolList}\n</available-deferred-tools>\nTo invoke any tool listed above, use ExecuteExtraTool with {"tool_name": "<name>", "params": {...}}. This is the ONLY way to call deferred tools — do not read source code or analyze implementation, just call ExecuteExtraTool directly.`,
isMeta: true,
}),
...messagesForAPI,
@@ -1425,7 +1425,7 @@ async function* queryModel(
isToolFromMcpServer(t.name, CLAUDE_IN_CHROME_MCP_SERVER_NAME),
)
const injectChromeHere =
useToolSearch && hasChromeTools && !isMcpInstructionsDeltaEnabled()
useSearchExtraTools && hasChromeTools && !isMcpInstructionsDeltaEnabled()
// filter(Boolean) works by converting each element to a boolean - empty strings become false and are filtered out.
systemPrompt = asSystemPrompt(
@@ -1437,7 +1437,7 @@ async function* queryModel(
}),
...systemPrompt,
...(advisorModel ? [ADVISOR_TOOL_INSTRUCTIONS] : []),
...(injectChromeHere ? [CHROME_TOOL_SEARCH_INSTRUCTIONS] : []),
...(injectChromeHere ? [CHROME_SEARCH_EXTRA_TOOLS_INSTRUCTIONS] : []),
].filter(Boolean),
)

View File

@@ -196,7 +196,7 @@ async function runQueryModel(
// We mock at module level. Bun's mock.module replaces the module for the
// entire file, so we configure the stream per-test via a shared variable.
let _nextEvents: BetaRawMessageStreamEvent[] = []
let _toolSearchEnabled = false
let _searchExtraToolsEnabled = false
/** Captured arguments from the last chat.completions.create() call */
let _lastCreateArgs: Record<string, any> | null = null
@@ -316,15 +316,15 @@ mock.module('../../../../utils/api.js', () => ({
toolToAPISchema: async (t: any) => t,
}))
mock.module('../../../../utils/toolSearch.js', () => ({
isToolSearchEnabled: async () => _toolSearchEnabled,
mock.module('../../../../utils/searchExtraTools.js', () => ({
isSearchExtraToolsEnabled: async () => _searchExtraToolsEnabled,
extractDiscoveredToolNames: () => new Set(),
isDeferredToolsDeltaEnabled: () => false,
}))
mock.module('../../../../tools/ToolSearchTool/prompt.js', () => ({
mock.module('../../../../tools/SearchExtraToolsTool/prompt.js', () => ({
isDeferredTool: () => false,
TOOL_SEARCH_TOOL_NAME: '__tool_search__',
SEARCH_EXTRA_TOOLS_TOOL_NAME: '__tool_search__',
}))
mock.module('../../../../cost-tracker.js', () => ({
@@ -606,14 +606,14 @@ describe('queryModelOpenAI — max_tokens forwarded to request', () => {
describe('queryModelOpenAI — deferred MCP tool visibility', () => {
test('prepends available deferred MCP tools to OpenAI messages', async () => {
_toolSearchEnabled = true
_searchExtraToolsEnabled = true
_nextEvents = [makeMessageStart(), makeMessageStop()]
try {
const { queryModelOpenAI } = await import('../index.js')
const tools: any[] = [
{
name: 'ToolSearch',
name: 'SearchExtraTools',
isMcp: false,
input_schema: { type: 'object', properties: {} },
prompt: async () => 'Search deferred tools',
@@ -655,7 +655,7 @@ describe('queryModelOpenAI — deferred MCP tool visibility', () => {
'<available-deferred-tools>\\nmcp__wechat__send_message\\n</available-deferred-tools>',
)
} finally {
_toolSearchEnabled = false
_searchExtraToolsEnabled = false
}
})
})

View File

@@ -52,14 +52,14 @@ import {
} from '../../../utils/messages.js'
import type { SDKAssistantMessageError } from '../../../entrypoints/agentSdkTypes.js'
import {
isToolSearchEnabled,
isSearchExtraToolsEnabled,
isDeferredToolsDeltaEnabled,
} from '../../../utils/toolSearch.js'
} from '../../../utils/searchExtraTools.js'
import {
formatDeferredToolLine,
isDeferredTool,
TOOL_SEARCH_TOOL_NAME,
} from '@claude-code-best/builtin-tools/tools/ToolSearchTool/prompt.js'
SEARCH_EXTRA_TOOLS_TOOL_NAME,
} from '@claude-code-best/builtin-tools/tools/SearchExtraToolsTool/prompt.js'
/**
* Mirrors the Anthropic request path's deferred-tool announcement for OpenAI.
@@ -67,15 +67,15 @@ import {
* OpenAI-compatible endpoints cannot consume Anthropic's `defer_loading` or
* `tool_reference` beta payloads directly, so the model needs the same textual
* list of deferred MCP tool names that Anthropic receives before it can ask
* ToolSearchTool to load their full schemas.
* SearchExtraToolsTool to load their full schemas.
*/
function prependDeferredToolListIfNeeded(
messages: (AssistantMessage | UserMessage)[],
tools: Tools,
deferredToolNames: Set<string>,
useToolSearch: boolean,
useSearchExtraTools: boolean,
): (AssistantMessage | UserMessage)[] {
if (!useToolSearch || isDeferredToolsDeltaEnabled()) return messages
if (!useSearchExtraTools || isDeferredToolsDeltaEnabled()) return messages
const deferredToolList = tools
.filter(tool => deferredToolNames.has(tool.name))
@@ -194,7 +194,7 @@ export async function* queryModelOpenAI(
const messagesForAPI = normalizeMessagesForAPI(messages, tools)
// 3. Check if tool search is enabled (similar to Anthropic path)
const useToolSearch = await isToolSearchEnabled(
const useSearchExtraTools = await isSearchExtraToolsEnabled(
options.model,
tools,
options.getToolPermissionContext ||
@@ -205,7 +205,7 @@ export async function* queryModelOpenAI(
// 4. Build deferred tools set (similar to Anthropic path)
const deferredToolNames = new Set<string>()
if (useToolSearch) {
if (useSearchExtraTools) {
for (const t of tools) {
if (isDeferredTool(t)) deferredToolNames.add(t.name)
}
@@ -216,12 +216,12 @@ export async function* queryModelOpenAI(
// 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) {
if (useSearchExtraTools && 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
// Always include SearchExtraToolsTool (so it can discover more tools)
if (toolMatchesName(tool, SEARCH_EXTRA_TOOLS_TOOL_NAME)) return true
// All other deferred tools are excluded — use ExecuteExtraTool instead
return false
})
@@ -236,7 +236,7 @@ export async function* queryModelOpenAI(
agents: options.agents,
allowedAgentTypes: options.allowedAgentTypes,
model: options.model,
deferLoading: useToolSearch && deferredToolNames.has(tool.name),
deferLoading: useSearchExtraTools && deferredToolNames.has(tool.name),
}),
),
)
@@ -260,7 +260,7 @@ export async function* queryModelOpenAI(
openAIConvertibleMessages,
tools,
deferredToolNames,
useToolSearch,
useSearchExtraTools,
)
const openaiMessages = anthropicMessagesToOpenAI(
messagesWithDeferredToolList,
@@ -271,7 +271,7 @@ export async function* queryModelOpenAI(
const openaiToolChoice = anthropicToolChoiceToOpenAI(options.toolChoice)
// 9. Log tool filtering details
if (useToolSearch) {
if (useSearchExtraTools) {
const includedDeferredTools = filteredTools.filter(t =>
deferredToolNames.has(t.name),
).length