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

@@ -63,7 +63,7 @@ const SAFE_READ_ONLY_TOOLS = new Set([
'Read',
'Glob',
'Grep',
'ToolSearch',
'SearchExtraTools',
'LSP',
'TaskGet',
'TaskList',

View File

@@ -482,7 +482,7 @@ describe('toolUpdateFromToolResult', () => {
is_error: false,
tool_use_id: 't1',
},
{ name: 'ToolSearch', id: 't1' },
{ name: 'SearchExtraTools', id: 't1' },
)
expect(result.content).toEqual([
{ type: 'content', content: { type: 'text', text: 'Tool: some_tool' } },

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

View File

@@ -19,7 +19,7 @@ import {
FILE_READ_TOOL_NAME,
FILE_UNCHANGED_STUB,
} from '@claude-code-best/builtin-tools/tools/FileReadTool/prompt.js'
import { ToolSearchTool } from '@claude-code-best/builtin-tools/tools/ToolSearchTool/ToolSearchTool.js'
import { SearchExtraToolsTool } from '@claude-code-best/builtin-tools/tools/SearchExtraToolsTool/SearchExtraToolsTool.js'
import type { AgentId } from '../../types/ids.js'
import type {
AssistantMessage,
@@ -92,8 +92,8 @@ import {
} from '../../utils/tokens.js'
import {
extractDiscoveredToolNames,
isToolSearchEnabled,
} from '../../utils/toolSearch.js'
isSearchExtraToolsEnabled,
} from '../../utils/searchExtraTools.js'
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../analytics/growthbook.js'
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
@@ -1296,7 +1296,7 @@ async function streamCompactSummary({
// Check if tool search is enabled using the main loop's tools list.
// context.options.tools includes MCP tools merged via useMergedTools.
const useToolSearch = await isToolSearchEnabled(
const useSearchExtraTools = await isSearchExtraToolsEnabled(
context.options.mainLoopModel,
context.options.tools,
async () => appState.toolPermissionContext,
@@ -1304,19 +1304,19 @@ async function streamCompactSummary({
'compact',
)
// When tool search is enabled, include ToolSearchTool and MCP tools. They get
// When tool search is enabled, include SearchExtraToolsTool and MCP tools. They get
// defer_loading: true and don't count against context - the API filters them out
// of system_prompt_tools before token counting (see api/token_count_api/counting.py:188
// and api/public_api/messages/handler.py:324).
// Filter MCP tools from context.options.tools (not appState.mcp.tools) so we
// get the permission-filtered set from useMergedTools — same source used for
// isToolSearchEnabled above and normalizeMessagesForAPI below.
// isSearchExtraToolsEnabled above and normalizeMessagesForAPI below.
// Deduplicate by name to avoid API errors when MCP tools share names with built-in tools.
const tools: Tool[] = useToolSearch
const tools: Tool[] = useSearchExtraTools
? uniqBy(
[
FileReadTool,
ToolSearchTool,
SearchExtraToolsTool,
...context.options.tools.filter(t => t.isMcp),
],
'name',

View File

@@ -17,7 +17,7 @@ import { getSessionMemoryPath } from '../../utils/permissions/filesystem.js'
import { processSessionStartHooks } from '../../utils/sessionStart.js'
import { getTranscriptPath } from '../../utils/sessionStorage.js'
import { tokenCountFromLastAPIResponse } from '../../utils/tokens.js'
import { extractDiscoveredToolNames } from '../../utils/toolSearch.js'
import { extractDiscoveredToolNames } from '../../utils/searchExtraTools.js'
import {
getDynamicConfig_BLOCKS_ON_INIT,
getFeatureValue_CACHED_MAY_BE_STALE,

View File

@@ -29,7 +29,7 @@ mock.module('src/services/analytics/growthbook.js', () => ({
getDynamicConfig_BLOCKS_ON_INIT: async () => undefined,
}))
// Mock skillSearch/prefetch.js (dependency of toolSearch/prefetch.ts)
// Mock skillSearch/prefetch.js (dependency of searchExtraTools/prefetch.ts)
mock.module('src/services/skillSearch/prefetch.js', () => ({
extractQueryFromMessages: (
_input: string | null,
@@ -60,7 +60,7 @@ mock.module('src/services/skillSearch/prefetch.js', () => ({
const mockGetToolIndex = mock(() => Promise.resolve([] as never[]))
const mockSearchTools = mock(() => [] as never[])
mock.module('src/services/toolSearch/toolIndex.js', () => ({
mock.module('src/services/searchExtraTools/toolIndex.js', () => ({
getToolIndex: mockGetToolIndex,
searchTools: mockSearchTools,
clearToolIndexCache: () => {},
@@ -73,9 +73,9 @@ mock.module('src/services/toolSearch/toolIndex.js', () => ({
}))
const {
startToolSearchPrefetch,
getTurnZeroToolSearchPrefetch,
collectToolSearchPrefetch,
startSearchExtraToolsPrefetch,
getTurnZeroSearchExtraToolsPrefetch,
collectSearchExtraToolsPrefetch,
buildToolDiscoveryAttachment,
} = await import('../prefetch.js')
@@ -89,7 +89,7 @@ function makeMockMessages(text: string) {
] as never
}
describe('startToolSearchPrefetch', () => {
describe('startSearchExtraToolsPrefetch', () => {
beforeEach(() => {
mockGetToolIndex.mockResolvedValue([
{ name: 'index-entry', tokens: ['test'], tfVector: new Map() },
@@ -110,7 +110,7 @@ describe('startToolSearchPrefetch', () => {
},
] as never)
const result = await startToolSearchPrefetch(
const result = await startSearchExtraToolsPrefetch(
[],
makeMockMessages('schedule a cron job'),
)
@@ -123,7 +123,7 @@ describe('startToolSearchPrefetch', () => {
})
test('returns empty array for empty query', async () => {
const result = await startToolSearchPrefetch([], [
const result = await startSearchExtraToolsPrefetch([], [
{ type: 'assistant', content: [] },
] as never)
expect(result).toEqual([])
@@ -131,7 +131,7 @@ describe('startToolSearchPrefetch', () => {
test('returns empty array when no tools match', async () => {
mockSearchTools.mockReturnValue([])
const result = await startToolSearchPrefetch(
const result = await startSearchExtraToolsPrefetch(
[],
makeMockMessages('quantum physics'),
)
@@ -140,12 +140,15 @@ describe('startToolSearchPrefetch', () => {
test('returns empty array on error (exception safety)', async () => {
mockGetToolIndex.mockRejectedValue(new Error('index failed'))
const result = await startToolSearchPrefetch([], makeMockMessages('test'))
const result = await startSearchExtraToolsPrefetch(
[],
makeMockMessages('test'),
)
expect(result).toEqual([])
})
})
describe('getTurnZeroToolSearchPrefetch', () => {
describe('getTurnZeroSearchExtraToolsPrefetch', () => {
beforeEach(() => {
mockGetToolIndex.mockResolvedValue([
{ name: 'index-entry', tokens: ['test'], tfVector: new Map() },
@@ -166,25 +169,31 @@ describe('getTurnZeroToolSearchPrefetch', () => {
},
] as never)
const result = await getTurnZeroToolSearchPrefetch('schedule cron job', [])
const result = await getTurnZeroSearchExtraToolsPrefetch(
'schedule cron job',
[],
)
expect(result).not.toBeNull()
expect(result!.type).toBe('tool_discovery')
expect((result as Record<string, unknown>).trigger).toBe('user_input')
})
test('returns null for empty input', async () => {
const result = await getTurnZeroToolSearchPrefetch('', [])
const result = await getTurnZeroSearchExtraToolsPrefetch('', [])
expect(result).toBeNull()
})
test('returns null when no tools match', async () => {
mockSearchTools.mockReturnValue([])
const result = await getTurnZeroToolSearchPrefetch('quantum physics', [])
const result = await getTurnZeroSearchExtraToolsPrefetch(
'quantum physics',
[],
)
expect(result).toBeNull()
})
})
describe('collectToolSearchPrefetch', () => {
describe('collectSearchExtraToolsPrefetch', () => {
test('returns resolved attachment array', async () => {
const attachment = {
type: 'tool_discovery' as const,
@@ -194,7 +203,7 @@ describe('collectToolSearchPrefetch', () => {
durationMs: 10,
indexSize: 5,
}
const result = await collectToolSearchPrefetch(
const result = await collectSearchExtraToolsPrefetch(
Promise.resolve([
attachment,
] as unknown as import('../../../utils/attachments.js').Attachment[]),
@@ -204,7 +213,7 @@ describe('collectToolSearchPrefetch', () => {
})
test('returns empty array on rejected promise', async () => {
const result = await collectToolSearchPrefetch(
const result = await collectSearchExtraToolsPrefetch(
Promise.reject(new Error('fail')),
)
expect(result).toEqual([])

View File

@@ -4,7 +4,7 @@ import type { Tools } from '../../Tool.js'
import {
getToolIndex,
searchTools,
type ToolSearchResult,
type SearchExtraToolsResult,
} from './toolIndex.js'
import { logForDebugging } from '../../utils/debug.js'
import { extractQueryFromMessages } from '../skillSearch/prefetch.js'
@@ -31,7 +31,7 @@ function notifyPrefetchListeners(): void {
for (const listener of prefetchListeners) listener()
}
export function subscribeToToolSearchPrefetch(
export function subscribeToSearchExtraToolsPrefetch(
listener: () => void,
): () => void {
prefetchListeners.add(listener)
@@ -40,11 +40,11 @@ export function subscribeToToolSearchPrefetch(
}
}
export function getToolSearchPrefetchSnapshot(): ToolDiscoveryResult[] {
export function getSearchExtraToolsPrefetchSnapshot(): ToolDiscoveryResult[] {
return latestPrefetchResult
}
export function clearToolSearchPrefetchResults(): void {
export function clearSearchExtraToolsPrefetchResults(): void {
latestPrefetchResult = []
notifyPrefetchListeners()
}
@@ -62,7 +62,7 @@ function addBoundedSessionEntry(set: Set<string>, value: string): void {
}
}
function toDiscoveryResult(r: ToolSearchResult): ToolDiscoveryResult {
function toDiscoveryResult(r: SearchExtraToolsResult): ToolDiscoveryResult {
return {
name: r.name,
description: r.description,
@@ -91,7 +91,7 @@ export function buildToolDiscoveryAttachment(
} as Attachment
}
export async function startToolSearchPrefetch(
export async function startSearchExtraToolsPrefetch(
tools: Tools,
messages: Message[],
): Promise<Attachment[]> {
@@ -113,7 +113,7 @@ export async function startToolSearchPrefetch(
const durationMs = Date.now() - startedAt
logForDebugging(
`[tool-search] prefetch found ${newResults.length} tools in ${durationMs}ms`,
`[search-extra-tools] prefetch found ${newResults.length} tools in ${durationMs}ms`,
)
const discoveryResults = newResults.map(toDiscoveryResult)
@@ -130,50 +130,22 @@ export async function startToolSearchPrefetch(
),
]
} catch (error) {
logForDebugging(`[tool-search] prefetch error: ${error}`)
logForDebugging(`[search-extra-tools] prefetch error: ${error}`)
return []
}
}
export async function getTurnZeroToolSearchPrefetch(
input: string,
tools: Tools,
export async function getTurnZeroSearchExtraToolsPrefetch(
_input: string,
_tools: Tools,
): Promise<Attachment | null> {
if (!input.trim()) return null
const startedAt = Date.now()
try {
const index = await getToolIndex(tools)
const results = searchTools(input, index, 3)
if (results.length === 0) return null
for (const r of results)
addBoundedSessionEntry(discoveredToolsThisSession, r.name)
const durationMs = Date.now() - startedAt
logForDebugging(
`[tool-search] turn-zero found ${results.length} tools in ${durationMs}ms`,
)
const discoveryResults = results.map(toDiscoveryResult)
latestPrefetchResult = discoveryResults
notifyPrefetchListeners()
return buildToolDiscoveryAttachment(
discoveryResults,
'user_input',
input,
durationMs,
index.length,
)
} catch (error) {
logForDebugging(`[tool-search] turn-zero error: ${error}`)
return null
}
// Disabled: turn-zero user-input tool recommendations caused frequent
// popups. Inter-turn discovery (startSearchExtraToolsPrefetch) is still
// active and provides non-intrusive suggestions during assistant turns.
return null
}
export async function collectToolSearchPrefetch(
export async function collectSearchExtraToolsPrefetch(
pending: Promise<Attachment[]>,
): Promise<Attachment[]> {
try {

View File

@@ -6,7 +6,7 @@ import {
computeIdf,
cosineSimilarity,
} from '../skillSearch/localSearch.js'
import { isDeferredTool } from '@claude-code-best/builtin-tools/tools/ToolSearchTool/prompt.js'
import { isDeferredTool } from '@claude-code-best/builtin-tools/tools/SearchExtraToolsTool/prompt.js'
export interface ToolIndexEntry {
name: string
@@ -20,7 +20,7 @@ export interface ToolIndexEntry {
tfVector: Map<string, number>
}
export interface ToolSearchResult {
export interface SearchExtraToolsResult {
name: string
description: string
searchHint: string | undefined
@@ -36,8 +36,8 @@ const TOOL_FIELD_WEIGHT = {
description: 1.0,
} as const
const TOOL_SEARCH_DISPLAY_MIN_SCORE = Number(
process.env.TOOL_SEARCH_DISPLAY_MIN_SCORE ?? '0.10',
const SEARCH_EXTRA_TOOLS_DISPLAY_MIN_SCORE = Number(
process.env.SEARCH_EXTRA_TOOLS_DISPLAY_MIN_SCORE ?? '0.10',
)
const CJK_MIN_BIGRAM_MATCHES = 2
@@ -143,7 +143,7 @@ export async function buildToolIndex(tools: Tools): Promise<ToolIndexEntry[]> {
}
logForDebugging(
`[tool-search] indexed ${entries.length} deferred tools from ${tools.length} total tools`,
`[search-extra-tools] indexed ${entries.length} deferred tools from ${tools.length} total tools`,
)
return entries
}
@@ -152,7 +152,7 @@ export function searchTools(
query: string,
index: ToolIndexEntry[],
limit = 5,
): ToolSearchResult[] {
): SearchExtraToolsResult[] {
if (index.length === 0 || !query.trim()) return []
const queryTokens = tokenizeAndStem(query)
@@ -175,7 +175,7 @@ export function searchTools(
const queryAsciiTokens = queryTokens.filter(t => !isCjk(t[0] ?? ''))
const queryLower = query.toLowerCase().replace(/[-_]/g, ' ')
const results: ToolSearchResult[] = []
const results: SearchExtraToolsResult[] = []
for (const entry of index) {
let score = cosineSimilarity(queryTfIdf, entry.tfVector)
@@ -191,7 +191,7 @@ export function searchTools(
score = Math.max(score, 0.75)
}
if (score >= TOOL_SEARCH_DISPLAY_MIN_SCORE) {
if (score >= SEARCH_EXTRA_TOOLS_DISPLAY_MIN_SCORE) {
results.push({
name: entry.name,
description: entry.description,
@@ -229,5 +229,5 @@ export async function getToolIndex(tools: Tools): Promise<ToolIndexEntry[]> {
export function clearToolIndexCache(): void {
cachedIndex = null
cachedToolNames = null
logForDebugging('[tool-search] index cache cleared')
logForDebugging('[search-extra-tools] index cache cleared')
}

View File

@@ -22,7 +22,7 @@ import {
normalizeModelStringForAPI,
} from '../utils/model/model.js'
import { jsonStringify } from '../utils/slowOperations.js'
import { isToolReferenceBlock } from '../utils/toolSearch.js'
import { isToolReferenceBlock } from '../utils/searchExtraTools.js'
import { getAPIMetadata, getExtraBodyParams } from './api/claude.js'
import { getAnthropicClient } from './api/client.js'
import {
@@ -70,7 +70,7 @@ function hasThinkingBlocks(
* Note: We use 'as unknown as' casts because the SDK types don't include tool search beta fields,
* but at runtime these fields may exist from API responses when tool search was enabled.
*/
function stripToolSearchFieldsFromMessages(
function stripSearchExtraToolsFieldsFromMessages(
messages: Anthropic.Beta.Messages.BetaMessageParam[],
): Anthropic.Beta.Messages.BetaMessageParam[] {
return messages.map(message => {
@@ -285,7 +285,7 @@ export async function countTokensViaHaikuFallback(
// Otherwise always use Haiku - Haiku 4.5 supports thinking blocks.
// WARNING: if you change this to use a non-Haiku model, this request will fail in 1P unless it uses getCLISyspromptPrefix.
// Note: We don't need Sonnet for tool_reference blocks because we strip them via
// stripToolSearchFieldsFromMessages() before sending.
// stripSearchExtraToolsFieldsFromMessages() before sending.
// Use getSmallFastModel() to respect ANTHROPIC_SMALL_FAST_MODEL env var for Bedrock users
// with global inference profiles (see issue #10883).
const model =
@@ -300,7 +300,7 @@ export async function countTokensViaHaikuFallback(
// Strip tool search-specific fields (caller, tool_reference) before sending
// These fields are only valid with the tool search beta header
const normalizedMessages = stripToolSearchFieldsFromMessages(messages)
const normalizedMessages = stripSearchExtraToolsFieldsFromMessages(messages)
const messagesToSend: MessageParam[] =
normalizedMessages.length > 0

View File

@@ -46,8 +46,8 @@ import { POWERSHELL_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/Powe
import { parseGitCommitId } from '@claude-code-best/builtin-tools/tools/shared/gitOperationTracking.js'
import {
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 { getAllBaseTools } from '../../tools.js'
import type { HookProgress } from '../../types/hooks.js'
import { recordToolObservation } from '../langfuse/index.js'
@@ -109,9 +109,9 @@ import {
} from '../../utils/toolResultStorage.js'
import {
extractDiscoveredToolNames,
isToolSearchEnabledOptimistic,
isToolSearchToolAvailable,
} from '../../utils/toolSearch.js'
isSearchExtraToolsEnabledOptimistic,
isSearchExtraToolsToolAvailable,
} from '../../utils/searchExtraTools.js'
import {
McpAuthError,
McpToolCallError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
@@ -609,12 +609,12 @@ export function buildSchemaNotSentHint(
messages: Message[],
tools: readonly { name: string }[],
): string | null {
// Optimistic gating — reconstructing claude.ts's full useToolSearch
// computation is fragile. These two gates prevent pointing at a ToolSearch
// Optimistic gating — reconstructing claude.ts's full useSearchExtraTools
// computation is fragile. These two gates prevent pointing at a SearchExtraTools
// that isn't callable; occasional misfires (Haiku, tst-auto below threshold)
// cost one extra round-trip on an already-failing path.
if (!isToolSearchEnabledOptimistic()) return null
if (!isToolSearchToolAvailable(tools)) return null
if (!isSearchExtraToolsEnabledOptimistic()) return null
if (!isSearchExtraToolsToolAvailable(tools)) return null
if (!isDeferredTool(tool)) return null
const discovered = extractDiscoveredToolNames(messages)
if (discovered.has(tool.name)) return null
@@ -626,14 +626,14 @@ export function buildSchemaNotSentHint(
return (
`\n\nTool "${toolDisplayName}" is deferred-loading and needs to be discovered before use.\n` +
`When using OpenAI-compatible models (DeepSeek, Ollama, etc.), follow these steps:\n` +
`1. First discover the tool with ToolSearch: ${TOOL_SEARCH_TOOL_NAME}("select:${tool.name}")\n` +
`1. First discover the tool with SearchExtraTools: ${SEARCH_EXTRA_TOOLS_TOOL_NAME}("select:${tool.name}")\n` +
`2. Then call ${toolDisplayName} tool\n` +
`\nExample:\n` +
`${TOOL_SEARCH_TOOL_NAME}("select:${tool.name}") → ${toolDisplayName}({ ... })\n` +
`${SEARCH_EXTRA_TOOLS_TOOL_NAME}("select:${tool.name}") → ${toolDisplayName}({ ... })\n` +
`\nImportant notes:\n` +
`• Use camelCase parameter names (e.g., taskId), not snake_case (task_id)\n` +
`• All task tools (TaskGet, TaskCreate, TaskUpdate, TaskList) need to be discovered first\n` +
`• You can discover them all at once: ${TOOL_SEARCH_TOOL_NAME}("select:TaskGet,TaskCreate,TaskUpdate,TaskList")\n` +
`• You can discover them all at once: ${SEARCH_EXTRA_TOOLS_TOOL_NAME}("select:TaskGet,TaskCreate,TaskUpdate,TaskList")\n` +
`\nSee docs/openai-task-tools.md for detailed guide.`
)
}