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),
)