mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 06:15:51 +00:00
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:
@@ -63,7 +63,7 @@ const SAFE_READ_ONLY_TOOLS = new Set([
|
||||
'Read',
|
||||
'Glob',
|
||||
'Grep',
|
||||
'ToolSearch',
|
||||
'SearchExtraTools',
|
||||
'LSP',
|
||||
'TaskGet',
|
||||
'TaskList',
|
||||
|
||||
@@ -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' } },
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([])
|
||||
@@ -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 {
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.`
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user