mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 13:55:50 +00:00
Merge pull request #442 from claude-code-best/feature/tool_search
feat: 支持 SearchExtraTools 能力以替代 Tool Search
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' } },
|
||||
|
||||
@@ -157,13 +157,12 @@ import {
|
||||
import { getAgentContext } from 'src/utils/agentContext.js'
|
||||
import { isClaudeAISubscriber } from 'src/utils/auth.js'
|
||||
import {
|
||||
getToolSearchBetaHeader,
|
||||
modelSupportsStructuredOutputs,
|
||||
shouldIncludeFirstPartyOnlyBetas,
|
||||
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,17 +184,16 @@ import {
|
||||
type ThinkingConfig,
|
||||
} from 'src/utils/thinking.js'
|
||||
import {
|
||||
extractDiscoveredToolNames,
|
||||
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'
|
||||
@@ -1157,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,
|
||||
@@ -1167,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)
|
||||
}
|
||||
@@ -1175,51 +1173,46 @@ 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
|
||||
}
|
||||
|
||||
// Filter out ToolSearchTool if tool search is not enabled for this model
|
||||
// ToolSearchTool returns tool_reference blocks which unsupported models can't handle
|
||||
// Dynamic tool loading: filter deferred tools that haven't been discovered yet
|
||||
let filteredTools: Tools
|
||||
|
||||
if (useToolSearch) {
|
||||
// Dynamic tool loading: Only include deferred tools that have been discovered
|
||||
// via tool_reference blocks in the message history. This eliminates the need
|
||||
// to predeclare all deferred tools upfront and removes limits on tool quantity.
|
||||
const discoveredToolNames = extractDiscoveredToolNames(messages)
|
||||
// Deferred tools that haven't been discovered are filtered out from the API
|
||||
// request — their schemas are only included after SearchExtraTools discovers them.
|
||||
|
||||
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
|
||||
// across turns (discovered tools no longer bloat the tools JSON).
|
||||
filteredTools = tools.filter(tool => {
|
||||
// Always include non-deferred tools
|
||||
// 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
|
||||
// Only include deferred tools that have been discovered
|
||||
return discoveredToolNames.has(tool.name)
|
||||
// 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),
|
||||
)
|
||||
}
|
||||
|
||||
// Add tool search beta header if enabled - required for defer_loading to be accepted
|
||||
// Header differs by provider: 1P/Foundry use advanced-tool-use, Vertex/Bedrock use tool-search-tool
|
||||
// For Bedrock, this header must go in extraBodyParams, not the betas array
|
||||
const toolSearchHeader = useToolSearch ? getToolSearchBetaHeader() : null
|
||||
if (toolSearchHeader && getAPIProvider() !== 'bedrock') {
|
||||
if (!betas.includes(toolSearchHeader)) {
|
||||
betas.push(toolSearchHeader)
|
||||
}
|
||||
}
|
||||
// Tool search beta header and defer_loading removed — unified self-built
|
||||
// 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.
|
||||
// Computed once here (in async context) and captured by paramsFromContext.
|
||||
@@ -1250,13 +1243,9 @@ async function* queryModel(
|
||||
}
|
||||
|
||||
const useGlobalCacheFeature = shouldUseGlobalCacheScope()
|
||||
const willDefer = (t: Tool) =>
|
||||
useToolSearch && (deferredToolNames.has(t.name) || shouldDeferLspTool(t))
|
||||
// MCP tools are per-user → dynamic tool section → can't globally cache.
|
||||
// Only gate when an MCP tool will actually render (not defer_loading).
|
||||
const needsToolBasedCacheMarker =
|
||||
useGlobalCacheFeature &&
|
||||
filteredTools.some(t => t.isMcp === true && !willDefer(t))
|
||||
useGlobalCacheFeature && filteredTools.some(t => t.isMcp === true)
|
||||
|
||||
// Ensure prompt_caching_scope beta header is present when global cache is enabled.
|
||||
if (
|
||||
@@ -1273,9 +1262,9 @@ async function* queryModel(
|
||||
: 'system_prompt'
|
||||
: 'none'
|
||||
|
||||
// Build tool schemas, adding defer_loading for MCP tools when tool search is enabled
|
||||
// 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 =>
|
||||
@@ -1285,17 +1274,13 @@ async function* queryModel(
|
||||
agents: options.agents,
|
||||
allowedAgentTypes: options.allowedAgentTypes,
|
||||
model: options.model,
|
||||
deferLoading: willDefer(tool),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
if (useToolSearch) {
|
||||
const includedDeferredTools = count(filteredTools, t =>
|
||||
deferredToolNames.has(t.name),
|
||||
)
|
||||
if (useSearchExtraTools) {
|
||||
logForDebugging(
|
||||
`Dynamic tool loading: ${includedDeferredTools}/${deferredToolNames.size} deferred tools included`,
|
||||
`Dynamic tool loading: 0/${deferredToolNames.size} deferred tools in API tools array (all via ExecuteExtraTool)`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1315,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':
|
||||
@@ -1365,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,
|
||||
@@ -1415,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)
|
||||
@@ -1424,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,
|
||||
@@ -1440,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(
|
||||
@@ -1452,7 +1437,7 @@ async function* queryModel(
|
||||
}),
|
||||
...systemPrompt,
|
||||
...(advisorModel ? [ADVISOR_TOOL_INSTRUCTIONS] : []),
|
||||
...(injectChromeHere ? [CHROME_TOOL_SEARCH_INSTRUCTIONS] : []),
|
||||
...(injectChromeHere ? [CHROME_SEARCH_EXTRA_TOOLS_INSTRUCTIONS] : []),
|
||||
].filter(Boolean),
|
||||
)
|
||||
|
||||
@@ -1653,13 +1638,10 @@ async function* queryModel(
|
||||
betasParams.push(CONTEXT_1M_BETA_HEADER)
|
||||
}
|
||||
|
||||
// For Bedrock, include both model-based betas and dynamically-added tool search header
|
||||
// For Bedrock, include model-based betas (no tool search header — self-built search)
|
||||
const bedrockBetas =
|
||||
getAPIProvider() === 'bedrock'
|
||||
? [
|
||||
...getBedrockExtraBodyParamsBetas(retryContext.model),
|
||||
...(toolSearchHeader ? [toolSearchHeader] : []),
|
||||
]
|
||||
? [...getBedrockExtraBodyParamsBetas(retryContext.model)]
|
||||
: []
|
||||
const extraBodyParams = getExtraBodyParams(bedrockBetas)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -59,15 +59,14 @@ import {
|
||||
} from '../../../utils/messages.js'
|
||||
import type { SDKAssistantMessageError } from '../../../entrypoints/agentSdkTypes.js'
|
||||
import {
|
||||
isToolSearchEnabled,
|
||||
extractDiscoveredToolNames,
|
||||
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'
|
||||
|
||||
function convertToResponsesReasoningEffort(
|
||||
effortValue: unknown,
|
||||
@@ -98,15 +97,15 @@ function getChatGPTResponsesReasoningEffort(
|
||||
* 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))
|
||||
@@ -225,7 +224,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 ||
|
||||
@@ -236,24 +235,25 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Filter tools (similar to Anthropic path)
|
||||
// Never include deferred tools in the API tools array — they are invoked
|
||||
// via ExecuteExtraTool which looks them up from the global tool registry
|
||||
// at runtime. Keeping the tools array stable preserves the prompt cache.
|
||||
let filteredTools = tools
|
||||
if (useToolSearch && deferredToolNames.size > 0) {
|
||||
const discoveredToolNames = extractDiscoveredToolNames(messages)
|
||||
|
||||
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
|
||||
// Only include deferred tools that have been discovered
|
||||
return discoveredToolNames.has(tool.name)
|
||||
// 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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -266,7 +266,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),
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -290,7 +290,7 @@ export async function* queryModelOpenAI(
|
||||
openAIConvertibleMessages,
|
||||
tools,
|
||||
deferredToolNames,
|
||||
useToolSearch,
|
||||
useSearchExtraTools,
|
||||
)
|
||||
const openaiMessages = anthropicMessagesToOpenAI(
|
||||
messagesWithDeferredToolList,
|
||||
@@ -304,7 +304,7 @@ export async function* queryModelOpenAI(
|
||||
)
|
||||
|
||||
// 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,
|
||||
|
||||
247
src/services/searchExtraTools/__tests__/prefetch.runner.ts
Normal file
247
src/services/searchExtraTools/__tests__/prefetch.runner.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { describe, test, expect, beforeEach } from 'bun:test'
|
||||
import { mock } from 'bun:test'
|
||||
import { logMock } from '../../../../tests/mocks/log'
|
||||
import { debugMock } from '../../../../tests/mocks/debug'
|
||||
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
mock.module('src/utils/debug.ts', debugMock)
|
||||
mock.module('src/services/analytics/growthbook.js', () => ({
|
||||
getFeatureValue_CACHED_MAY_BE_STALE: () => false,
|
||||
checkStatsigFeatureGate_CACHED_MAY_BE_STALE: () => false,
|
||||
getFeatureValue_DEPRECATED: async () => undefined,
|
||||
getFeatureValue_CACHED_WITH_REFRESH: async () => undefined,
|
||||
hasGrowthBookEnvOverride: () => false,
|
||||
getAllGrowthBookFeatures: () => ({}),
|
||||
getGrowthBookConfigOverrides: () => ({}),
|
||||
setGrowthBookConfigOverride: () => {},
|
||||
clearGrowthBookConfigOverrides: () => {},
|
||||
getApiBaseUrlHost: () => undefined,
|
||||
onGrowthBookRefresh: () => {},
|
||||
initializeGrowthBook: async () => {},
|
||||
checkSecurityRestrictionGate: async () => false,
|
||||
checkGate_CACHED_OR_BLOCKING: async () => false,
|
||||
refreshGrowthBookAfterAuthChange: () => {},
|
||||
resetGrowthBook: () => {},
|
||||
refreshGrowthBookFeatures: async () => {},
|
||||
setupPeriodicGrowthBookRefresh: () => {},
|
||||
stopPeriodicGrowthBookRefresh: () => {},
|
||||
getDynamicConfig_CACHED_MAY_BE_STALE: () => undefined,
|
||||
getDynamicConfig_BLOCKS_ON_INIT: async () => undefined,
|
||||
}))
|
||||
|
||||
// Mock skillSearch/prefetch.js (dependency of searchExtraTools/prefetch.ts)
|
||||
mock.module('src/services/skillSearch/prefetch.js', () => ({
|
||||
extractQueryFromMessages: (
|
||||
_input: string | null,
|
||||
messages: { type: string; content: unknown }[],
|
||||
) => {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i]!
|
||||
if (msg.type !== 'user') continue
|
||||
const content = msg.content
|
||||
if (typeof content === 'string') return content
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (
|
||||
block &&
|
||||
typeof block === 'object' &&
|
||||
'text' in block &&
|
||||
typeof (block as { text: unknown }).text === 'string'
|
||||
) {
|
||||
return (block as { text: string }).text
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ''
|
||||
},
|
||||
}))
|
||||
|
||||
const mockGetToolIndex = mock(() => Promise.resolve([] as never[]))
|
||||
const mockSearchTools = mock(() => [] as never[])
|
||||
|
||||
mock.module('src/services/searchExtraTools/toolIndex.js', () => ({
|
||||
getToolIndex: mockGetToolIndex,
|
||||
searchTools: mockSearchTools,
|
||||
clearToolIndexCache: () => {},
|
||||
buildToolIndex: async () => [],
|
||||
parseToolName: (name: string) => ({
|
||||
parts: name.toLowerCase().split('_'),
|
||||
full: name.toLowerCase(),
|
||||
isMcp: name.startsWith('mcp__'),
|
||||
}),
|
||||
}))
|
||||
|
||||
const {
|
||||
startSearchExtraToolsPrefetch,
|
||||
getTurnZeroSearchExtraToolsPrefetch,
|
||||
collectSearchExtraToolsPrefetch,
|
||||
buildToolDiscoveryAttachment,
|
||||
} = await import('../prefetch.js')
|
||||
|
||||
function makeMockMessages(text: string) {
|
||||
return [
|
||||
{
|
||||
type: 'user',
|
||||
content: [{ type: 'text', text }],
|
||||
uuid: 'test-uuid',
|
||||
},
|
||||
] as never
|
||||
}
|
||||
|
||||
describe('startSearchExtraToolsPrefetch', () => {
|
||||
beforeEach(() => {
|
||||
mockGetToolIndex.mockResolvedValue([
|
||||
{ name: 'index-entry', tokens: ['test'], tfVector: new Map() },
|
||||
] as never)
|
||||
mockSearchTools.mockReturnValue([])
|
||||
})
|
||||
|
||||
test('returns tool_discovery attachment for matching tools', async () => {
|
||||
mockSearchTools.mockReturnValue([
|
||||
{
|
||||
name: 'CronCreateTool',
|
||||
description: 'Create cron jobs',
|
||||
searchHint: 'schedule recurring',
|
||||
score: 0.5,
|
||||
isMcp: false,
|
||||
isDeferred: true,
|
||||
inputSchema: undefined,
|
||||
},
|
||||
] as never)
|
||||
|
||||
const result = await startSearchExtraToolsPrefetch(
|
||||
[],
|
||||
makeMockMessages('schedule a cron job'),
|
||||
)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]!.type).toBe('tool_discovery')
|
||||
expect((result[0] as Record<string, unknown>).trigger).toBe(
|
||||
'assistant_turn',
|
||||
)
|
||||
expect((result[0] as Record<string, unknown>).tools).toBeDefined()
|
||||
})
|
||||
|
||||
test('returns empty array for empty query', async () => {
|
||||
const result = await startSearchExtraToolsPrefetch([], [
|
||||
{ type: 'assistant', content: [] },
|
||||
] as never)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
test('returns empty array when no tools match', async () => {
|
||||
mockSearchTools.mockReturnValue([])
|
||||
const result = await startSearchExtraToolsPrefetch(
|
||||
[],
|
||||
makeMockMessages('quantum physics'),
|
||||
)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
test('returns empty array on error (exception safety)', async () => {
|
||||
mockGetToolIndex.mockRejectedValue(new Error('index failed'))
|
||||
const result = await startSearchExtraToolsPrefetch(
|
||||
[],
|
||||
makeMockMessages('test'),
|
||||
)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTurnZeroSearchExtraToolsPrefetch', () => {
|
||||
// Turn-zero user-input tool recommendations are disabled to avoid frequent
|
||||
// popups. All cases return null regardless of input/match state.
|
||||
test('returns null (feature disabled)', async () => {
|
||||
mockGetToolIndex.mockResolvedValue([
|
||||
{ name: 'index-entry', tokens: ['test'], tfVector: new Map() },
|
||||
] as never)
|
||||
mockSearchTools.mockReturnValue([
|
||||
{
|
||||
name: 'CronCreateTool',
|
||||
description: 'Create cron jobs',
|
||||
searchHint: 'schedule recurring',
|
||||
score: 0.5,
|
||||
isMcp: false,
|
||||
isDeferred: true,
|
||||
inputSchema: undefined,
|
||||
},
|
||||
] as never)
|
||||
|
||||
const result = await getTurnZeroSearchExtraToolsPrefetch(
|
||||
'schedule cron job',
|
||||
[],
|
||||
)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test('returns null for empty input', async () => {
|
||||
const result = await getTurnZeroSearchExtraToolsPrefetch('', [])
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test('returns null when no tools match', async () => {
|
||||
mockSearchTools.mockReturnValue([])
|
||||
const result = await getTurnZeroSearchExtraToolsPrefetch(
|
||||
'quantum physics',
|
||||
[],
|
||||
)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectSearchExtraToolsPrefetch', () => {
|
||||
test('returns resolved attachment array', async () => {
|
||||
const attachment = {
|
||||
type: 'tool_discovery' as const,
|
||||
tools: [],
|
||||
trigger: 'assistant_turn' as const,
|
||||
queryText: 'test',
|
||||
durationMs: 10,
|
||||
indexSize: 5,
|
||||
}
|
||||
const result = await collectSearchExtraToolsPrefetch(
|
||||
Promise.resolve([
|
||||
attachment,
|
||||
] as unknown as import('../../../utils/attachments.js').Attachment[]),
|
||||
)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]!.type).toBe('tool_discovery')
|
||||
})
|
||||
|
||||
test('returns empty array on rejected promise', async () => {
|
||||
const result = await collectSearchExtraToolsPrefetch(
|
||||
Promise.reject(new Error('fail')),
|
||||
)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildToolDiscoveryAttachment', () => {
|
||||
test('returns attachment with all required fields', () => {
|
||||
const tools = [
|
||||
{
|
||||
name: 'TestTool',
|
||||
description: 'A test tool',
|
||||
searchHint: 'test',
|
||||
score: 0.5,
|
||||
isMcp: false,
|
||||
isDeferred: true,
|
||||
inputSchema: undefined,
|
||||
},
|
||||
]
|
||||
const attachment = buildToolDiscoveryAttachment(
|
||||
tools,
|
||||
'user_input',
|
||||
'test query',
|
||||
10,
|
||||
5,
|
||||
)
|
||||
const att = attachment as Record<string, unknown>
|
||||
expect(att.type).toBe('tool_discovery')
|
||||
expect(att.tools).toBe(tools)
|
||||
expect(att.trigger).toBe('user_input')
|
||||
expect(att.queryText).toBe('test query')
|
||||
expect(att.durationMs).toBe(10)
|
||||
expect(att.indexSize).toBe(5)
|
||||
})
|
||||
})
|
||||
33
src/services/searchExtraTools/__tests__/prefetch.test.ts
Normal file
33
src/services/searchExtraTools/__tests__/prefetch.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* prefetch.test.ts
|
||||
*
|
||||
* Thin subprocess wrapper that runs the actual tests in an isolated bun:test
|
||||
* process. This prevents mock.module() leaks from this file's toolIndex.js
|
||||
* mock from affecting other test files (e.g., toolIndex.test.ts).
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { resolve, relative } from 'path'
|
||||
|
||||
const PROJECT_ROOT = resolve(__dirname, '..', '..', '..', '..', '..')
|
||||
const RUNNER_ABS = resolve(__dirname, 'prefetch.runner.ts')
|
||||
const RUNNER_REL = './' + relative(PROJECT_ROOT, RUNNER_ABS).replace(/\\/g, '/')
|
||||
|
||||
describe('prefetch', () => {
|
||||
test('runs all prefetch tests in isolated subprocess', async () => {
|
||||
const proc = Bun.spawn(['bun', 'test', RUNNER_REL], {
|
||||
cwd: PROJECT_ROOT,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
const code = await proc.exited
|
||||
if (code !== 0) {
|
||||
const stderr = await new Response(proc.stderr).text()
|
||||
const stdout = await new Response(proc.stdout).text()
|
||||
const output = (stderr + '\n' + stdout).slice(-3000)
|
||||
throw new Error(
|
||||
`prefetch test subprocess failed (exit ${code}):\n${output}`,
|
||||
)
|
||||
}
|
||||
}, 60_000)
|
||||
})
|
||||
208
src/services/searchExtraTools/__tests__/toolIndex.test.ts
Normal file
208
src/services/searchExtraTools/__tests__/toolIndex.test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { describe, test, expect, beforeEach } from 'bun:test'
|
||||
import { mock } from 'bun:test'
|
||||
import { logMock } from '../../../../tests/mocks/log'
|
||||
import { debugMock } from '../../../../tests/mocks/debug'
|
||||
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
mock.module('src/utils/debug.ts', debugMock)
|
||||
mock.module('src/services/analytics/growthbook.js', () => ({
|
||||
getFeatureValue_CACHED_MAY_BE_STALE: () => false,
|
||||
checkStatsigFeatureGate_CACHED_MAY_BE_STALE: () => false,
|
||||
getFeatureValue_DEPRECATED: async () => undefined,
|
||||
getFeatureValue_CACHED_WITH_REFRESH: async () => undefined,
|
||||
hasGrowthBookEnvOverride: () => false,
|
||||
getAllGrowthBookFeatures: () => ({}),
|
||||
getGrowthBookConfigOverrides: () => ({}),
|
||||
setGrowthBookConfigOverride: () => {},
|
||||
clearGrowthBookConfigOverrides: () => {},
|
||||
getApiBaseUrlHost: () => undefined,
|
||||
onGrowthBookRefresh: () => {},
|
||||
initializeGrowthBook: async () => {},
|
||||
checkSecurityRestrictionGate: async () => false,
|
||||
checkGate_CACHED_OR_BLOCKING: async () => false,
|
||||
refreshGrowthBookAfterAuthChange: () => {},
|
||||
resetGrowthBook: () => {},
|
||||
refreshGrowthBookFeatures: async () => {},
|
||||
setupPeriodicGrowthBookRefresh: () => {},
|
||||
stopPeriodicGrowthBookRefresh: () => {},
|
||||
}))
|
||||
|
||||
const {
|
||||
parseToolName,
|
||||
buildToolIndex,
|
||||
searchTools,
|
||||
getToolIndex,
|
||||
clearToolIndexCache,
|
||||
} = await import('../toolIndex.js')
|
||||
|
||||
type MockTool = {
|
||||
name: string
|
||||
alwaysLoad?: boolean
|
||||
isMcp?: boolean
|
||||
shouldDefer?: boolean
|
||||
searchHint?: string
|
||||
prompt: () => Promise<string>
|
||||
inputJSONSchema?: object
|
||||
inputSchema?: unknown
|
||||
}
|
||||
|
||||
function makeMockTool(overrides: Partial<MockTool> = {}): MockTool {
|
||||
return {
|
||||
name: 'TestTool',
|
||||
isMcp: false,
|
||||
shouldDefer: undefined,
|
||||
alwaysLoad: undefined,
|
||||
searchHint: undefined,
|
||||
prompt: async () => 'A test tool for testing purposes.',
|
||||
inputJSONSchema: undefined,
|
||||
inputSchema: undefined,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('parseToolName', () => {
|
||||
test('parses MCP tool names', () => {
|
||||
const result = parseToolName('mcp__github__create_issue')
|
||||
expect(result.isMcp).toBe(true)
|
||||
expect(result.parts).toEqual(['github', 'create', 'issue'])
|
||||
})
|
||||
|
||||
test('parses built-in tool names', () => {
|
||||
const result = parseToolName('NotebookEditTool')
|
||||
expect(result.isMcp).toBe(false)
|
||||
expect(result.parts).toEqual(['notebook', 'edit', 'tool'])
|
||||
})
|
||||
|
||||
test('parses underscore-separated tool names', () => {
|
||||
const result = parseToolName('EnterWorktreeTool')
|
||||
expect(result.isMcp).toBe(false)
|
||||
expect(result.parts).toContain('enter')
|
||||
expect(result.parts).toContain('worktree')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildToolIndex', () => {
|
||||
test('builds index from deferred tools only', async () => {
|
||||
const tools = [
|
||||
makeMockTool({ name: 'CoreRead', alwaysLoad: true }),
|
||||
makeMockTool({
|
||||
name: 'ConfigTool',
|
||||
searchHint: 'configure settings options',
|
||||
prompt: async () => 'Manage configuration settings.',
|
||||
}),
|
||||
makeMockTool({
|
||||
name: 'CronCreateTool',
|
||||
searchHint: 'schedule recurring prompt',
|
||||
prompt: async () => 'Create cron jobs for scheduling.',
|
||||
}),
|
||||
] as unknown as import('../../../Tool.js').Tool[]
|
||||
|
||||
const index = await buildToolIndex(tools)
|
||||
// Only non-core, non-alwaysLoad tools should be indexed
|
||||
expect(index.length).toBe(2)
|
||||
for (const entry of index) {
|
||||
expect(entry.tokens.length).toBeGreaterThan(0)
|
||||
expect(entry.tfVector.size).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
test('returns empty array when all tools are core', async () => {
|
||||
const tools = [
|
||||
makeMockTool({ name: 'Read', alwaysLoad: true }),
|
||||
makeMockTool({ name: 'Edit', alwaysLoad: true }),
|
||||
] as unknown as import('../../../Tool.js').Tool[]
|
||||
|
||||
const index = await buildToolIndex(tools)
|
||||
expect(index.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('searchTools', () => {
|
||||
test('finds tools matching query', async () => {
|
||||
const tools = [
|
||||
makeMockTool({
|
||||
name: 'CronCreateTool',
|
||||
searchHint: 'schedule a recurring or one-shot prompt',
|
||||
prompt: async () => 'Create cron jobs for scheduling tasks.',
|
||||
}),
|
||||
makeMockTool({
|
||||
name: 'ConfigTool',
|
||||
searchHint: 'configure settings options',
|
||||
prompt: async () => 'Manage configuration settings.',
|
||||
}),
|
||||
] as unknown as import('../../../Tool.js').Tool[]
|
||||
|
||||
const index = await buildToolIndex(tools)
|
||||
const results = searchTools('schedule cron job', index)
|
||||
expect(results.length).toBeGreaterThan(0)
|
||||
// CronCreateTool should rank highest for "schedule cron job"
|
||||
expect(results[0]!.name).toBe('CronCreateTool')
|
||||
expect(results[0]!.score).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('returns empty array for empty query', async () => {
|
||||
const tools = [
|
||||
makeMockTool({
|
||||
name: 'ConfigTool',
|
||||
prompt: async () => 'Manage configuration.',
|
||||
}),
|
||||
] as unknown as import('../../../Tool.js').Tool[]
|
||||
|
||||
const index = await buildToolIndex(tools)
|
||||
expect(searchTools('', index)).toEqual([])
|
||||
})
|
||||
|
||||
test('returns empty array when no tools match', async () => {
|
||||
const tools = [
|
||||
makeMockTool({
|
||||
name: 'ConfigTool',
|
||||
prompt: async () => 'Manage configuration settings.',
|
||||
}),
|
||||
] as unknown as import('../../../Tool.js').Tool[]
|
||||
|
||||
const index = await buildToolIndex(tools)
|
||||
const results = searchTools('quantum physics entanglement', index)
|
||||
expect(results).toEqual([])
|
||||
})
|
||||
|
||||
test('CJK tokenization produces bigrams', async () => {
|
||||
// Verify CJK text is tokenized into bigrams (delegated to localSearch.tokenize)
|
||||
const { tokenizeAndStem } = await import('../../skillSearch/localSearch.js')
|
||||
const tokens = tokenizeAndStem('搜索代码')
|
||||
expect(tokens).toContain('搜索')
|
||||
expect(tokens).toContain('代码')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getToolIndex caching', () => {
|
||||
beforeEach(() => {
|
||||
clearToolIndexCache()
|
||||
})
|
||||
|
||||
test('returns cached index for same tool list', async () => {
|
||||
const tools = [
|
||||
makeMockTool({
|
||||
name: 'ConfigTool',
|
||||
prompt: async () => 'Manage configuration.',
|
||||
}),
|
||||
] as unknown as import('../../../Tool.js').Tool[]
|
||||
|
||||
const first = await getToolIndex(tools)
|
||||
const second = await getToolIndex(tools)
|
||||
expect(first).toBe(second) // Same reference = cached
|
||||
})
|
||||
|
||||
test('rebuilds index after clearToolIndexCache', async () => {
|
||||
const tools = [
|
||||
makeMockTool({
|
||||
name: 'ConfigTool',
|
||||
prompt: async () => 'Manage configuration.',
|
||||
}),
|
||||
] as unknown as import('../../../Tool.js').Tool[]
|
||||
|
||||
const first = await getToolIndex(tools)
|
||||
clearToolIndexCache()
|
||||
const second = await getToolIndex(tools)
|
||||
expect(first).not.toBe(second) // Different reference = rebuilt
|
||||
})
|
||||
})
|
||||
156
src/services/searchExtraTools/prefetch.ts
Normal file
156
src/services/searchExtraTools/prefetch.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import type { Attachment } from '../../utils/attachments.js'
|
||||
import type { Message } from '../../types/message.js'
|
||||
import type { Tools } from '../../Tool.js'
|
||||
import {
|
||||
getToolIndex,
|
||||
searchTools,
|
||||
type SearchExtraToolsResult,
|
||||
} from './toolIndex.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { extractQueryFromMessages } from '../skillSearch/prefetch.js'
|
||||
|
||||
export type ToolDiscoveryResult = {
|
||||
name: string
|
||||
description: string
|
||||
searchHint: string | undefined
|
||||
score: number
|
||||
isMcp: boolean
|
||||
isDeferred: boolean
|
||||
inputSchema: object | undefined
|
||||
}
|
||||
|
||||
const SESSION_TRACKING_MAX = 500
|
||||
const SESSION_TRACKING_TRIM_TO = 400
|
||||
const discoveredToolsThisSession = new Set<string>()
|
||||
|
||||
// Latest prefetch result for UI subscription (useSyncExternalStore)
|
||||
let latestPrefetchResult: ToolDiscoveryResult[] = []
|
||||
const prefetchListeners = new Set<() => void>()
|
||||
|
||||
function notifyPrefetchListeners(): void {
|
||||
for (const listener of prefetchListeners) listener()
|
||||
}
|
||||
|
||||
export function subscribeToSearchExtraToolsPrefetch(
|
||||
listener: () => void,
|
||||
): () => void {
|
||||
prefetchListeners.add(listener)
|
||||
return () => {
|
||||
prefetchListeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
export function getSearchExtraToolsPrefetchSnapshot(): ToolDiscoveryResult[] {
|
||||
return latestPrefetchResult
|
||||
}
|
||||
|
||||
export function clearSearchExtraToolsPrefetchResults(): void {
|
||||
latestPrefetchResult = []
|
||||
notifyPrefetchListeners()
|
||||
}
|
||||
|
||||
function addBoundedSessionEntry(set: Set<string>, value: string): void {
|
||||
set.add(value)
|
||||
if (set.size > SESSION_TRACKING_MAX) {
|
||||
const toDrop = set.size - SESSION_TRACKING_TRIM_TO
|
||||
const iter = set.values()
|
||||
for (let i = 0; i < toDrop; i++) {
|
||||
const next = iter.next()
|
||||
if (next.done) break
|
||||
set.delete(next.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toDiscoveryResult(r: SearchExtraToolsResult): ToolDiscoveryResult {
|
||||
return {
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
searchHint: r.searchHint,
|
||||
score: r.score,
|
||||
isMcp: r.isMcp,
|
||||
isDeferred: r.isDeferred,
|
||||
inputSchema: r.inputSchema,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildToolDiscoveryAttachment(
|
||||
tools: ToolDiscoveryResult[],
|
||||
trigger: 'assistant_turn' | 'user_input',
|
||||
queryText: string,
|
||||
durationMs: number,
|
||||
indexSize: number,
|
||||
): Attachment {
|
||||
return {
|
||||
type: 'tool_discovery',
|
||||
tools,
|
||||
trigger,
|
||||
queryText: queryText.slice(0, 200),
|
||||
durationMs,
|
||||
indexSize,
|
||||
} as Attachment
|
||||
}
|
||||
|
||||
export async function startSearchExtraToolsPrefetch(
|
||||
tools: Tools,
|
||||
messages: Message[],
|
||||
): Promise<Attachment[]> {
|
||||
const startedAt = Date.now()
|
||||
const queryText = extractQueryFromMessages(null, messages)
|
||||
if (!queryText.trim()) return []
|
||||
|
||||
try {
|
||||
const index = await getToolIndex(tools)
|
||||
const results = searchTools(queryText, index, 3)
|
||||
|
||||
const newResults = results.filter(
|
||||
r => !discoveredToolsThisSession.has(r.name),
|
||||
)
|
||||
if (newResults.length === 0) return []
|
||||
|
||||
for (const r of newResults)
|
||||
addBoundedSessionEntry(discoveredToolsThisSession, r.name)
|
||||
|
||||
const durationMs = Date.now() - startedAt
|
||||
logForDebugging(
|
||||
`[search-extra-tools] prefetch found ${newResults.length} tools in ${durationMs}ms`,
|
||||
)
|
||||
|
||||
const discoveryResults = newResults.map(toDiscoveryResult)
|
||||
latestPrefetchResult = discoveryResults
|
||||
notifyPrefetchListeners()
|
||||
|
||||
return [
|
||||
buildToolDiscoveryAttachment(
|
||||
discoveryResults,
|
||||
'assistant_turn',
|
||||
queryText,
|
||||
durationMs,
|
||||
index.length,
|
||||
),
|
||||
]
|
||||
} catch (error) {
|
||||
logForDebugging(`[search-extra-tools] prefetch error: ${error}`)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTurnZeroSearchExtraToolsPrefetch(
|
||||
_input: string,
|
||||
_tools: Tools,
|
||||
): Promise<Attachment | 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 collectSearchExtraToolsPrefetch(
|
||||
pending: Promise<Attachment[]>,
|
||||
): Promise<Attachment[]> {
|
||||
try {
|
||||
return await pending
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
233
src/services/searchExtraTools/toolIndex.ts
Normal file
233
src/services/searchExtraTools/toolIndex.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import type { Tools } from '../../Tool.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import {
|
||||
tokenizeAndStem,
|
||||
computeWeightedTf,
|
||||
computeIdf,
|
||||
cosineSimilarity,
|
||||
} from '../skillSearch/localSearch.js'
|
||||
import { isDeferredTool } from '@claude-code-best/builtin-tools/tools/SearchExtraToolsTool/prompt.js'
|
||||
|
||||
export interface ToolIndexEntry {
|
||||
name: string
|
||||
normalizedName: string
|
||||
description: string
|
||||
searchHint: string | undefined
|
||||
isMcp: boolean
|
||||
isDeferred: boolean
|
||||
inputSchema: object | undefined
|
||||
tokens: string[]
|
||||
tfVector: Map<string, number>
|
||||
}
|
||||
|
||||
export interface SearchExtraToolsResult {
|
||||
name: string
|
||||
description: string
|
||||
searchHint: string | undefined
|
||||
score: number
|
||||
isMcp: boolean
|
||||
isDeferred: boolean
|
||||
inputSchema: object | undefined
|
||||
}
|
||||
|
||||
const TOOL_FIELD_WEIGHT = {
|
||||
name: 3.0,
|
||||
searchHint: 2.5,
|
||||
description: 1.0,
|
||||
} as const
|
||||
|
||||
const SEARCH_EXTRA_TOOLS_DISPLAY_MIN_SCORE = Number(
|
||||
process.env.SEARCH_EXTRA_TOOLS_DISPLAY_MIN_SCORE ?? '0.10',
|
||||
)
|
||||
|
||||
const CJK_MIN_BIGRAM_MATCHES = 2
|
||||
|
||||
const CJK_RANGE = /[\u4e00-\u9fff\u3400-\u4dbf]/
|
||||
|
||||
function isCjk(ch: string): boolean {
|
||||
return CJK_RANGE.test(ch)
|
||||
}
|
||||
|
||||
export function parseToolName(name: string): {
|
||||
parts: string[]
|
||||
full: string
|
||||
isMcp: boolean
|
||||
} {
|
||||
if (name.startsWith('mcp__')) {
|
||||
const withoutPrefix = name.replace(/^mcp__/, '').toLowerCase()
|
||||
const parts = withoutPrefix.split('__').flatMap(p => p.split('_'))
|
||||
return {
|
||||
parts: parts.filter(Boolean),
|
||||
full: withoutPrefix.replace(/__/g, ' ').replace(/_/g, ' '),
|
||||
isMcp: true,
|
||||
}
|
||||
}
|
||||
|
||||
const parts = name
|
||||
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
.replace(/_/g, ' ')
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
|
||||
return {
|
||||
parts,
|
||||
full: parts.join(' '),
|
||||
isMcp: false,
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildToolIndex(tools: Tools): Promise<ToolIndexEntry[]> {
|
||||
const deferredTools = tools.filter(t => isDeferredTool(t))
|
||||
|
||||
const entries: ToolIndexEntry[] = []
|
||||
for (const tool of deferredTools) {
|
||||
let description = ''
|
||||
try {
|
||||
description = await tool.prompt({
|
||||
getToolPermissionContext: async () => ({
|
||||
mode: 'default' as const,
|
||||
additionalWorkingDirectories: new Map(),
|
||||
alwaysAllowRules: {},
|
||||
alwaysDenyRules: {},
|
||||
alwaysAskRules: {},
|
||||
isBypassPermissionsModeAvailable: false,
|
||||
}),
|
||||
tools,
|
||||
agents: [],
|
||||
})
|
||||
} catch {
|
||||
description = ''
|
||||
}
|
||||
|
||||
const { parts: nameParts, full: normalizedName } = parseToolName(tool.name)
|
||||
const searchHint = tool.searchHint ?? ''
|
||||
const nameTokens = tokenizeAndStem(nameParts.join(' '))
|
||||
const hintTokens = tokenizeAndStem(searchHint)
|
||||
const descTokens = tokenizeAndStem(description)
|
||||
|
||||
const allTokens = [
|
||||
...new Set([...nameTokens, ...hintTokens, ...descTokens]),
|
||||
]
|
||||
|
||||
const tfVector = computeWeightedTf([
|
||||
{ tokens: nameTokens, weight: TOOL_FIELD_WEIGHT.name },
|
||||
{ tokens: hintTokens, weight: TOOL_FIELD_WEIGHT.searchHint },
|
||||
{ tokens: descTokens, weight: TOOL_FIELD_WEIGHT.description },
|
||||
])
|
||||
|
||||
let inputSchema: object | undefined
|
||||
if (tool.inputJSONSchema) {
|
||||
inputSchema = tool.inputJSONSchema
|
||||
}
|
||||
|
||||
entries.push({
|
||||
name: tool.name,
|
||||
normalizedName,
|
||||
description,
|
||||
searchHint: tool.searchHint,
|
||||
isMcp: tool.isMcp === true,
|
||||
isDeferred: true,
|
||||
inputSchema,
|
||||
tokens: allTokens,
|
||||
tfVector,
|
||||
})
|
||||
}
|
||||
|
||||
const idf = computeIdf(entries)
|
||||
|
||||
for (const entry of entries) {
|
||||
for (const [term, tf] of entry.tfVector) {
|
||||
entry.tfVector.set(term, tf * (idf.get(term) ?? 0))
|
||||
}
|
||||
}
|
||||
|
||||
logForDebugging(
|
||||
`[search-extra-tools] indexed ${entries.length} deferred tools from ${tools.length} total tools`,
|
||||
)
|
||||
return entries
|
||||
}
|
||||
|
||||
export function searchTools(
|
||||
query: string,
|
||||
index: ToolIndexEntry[],
|
||||
limit = 5,
|
||||
): SearchExtraToolsResult[] {
|
||||
if (index.length === 0 || !query.trim()) return []
|
||||
|
||||
const queryTokens = tokenizeAndStem(query)
|
||||
if (queryTokens.length === 0) return []
|
||||
|
||||
const queryTf = new Map<string, number>()
|
||||
const freq = new Map<string, number>()
|
||||
for (const t of queryTokens) freq.set(t, (freq.get(t) ?? 0) + 1)
|
||||
let max = 1
|
||||
for (const v of freq.values()) if (v > max) max = v
|
||||
for (const [term, count] of freq) queryTf.set(term, count / max)
|
||||
|
||||
const idf = computeIdf(index)
|
||||
const queryTfIdf = new Map<string, number>()
|
||||
for (const [term, tf] of queryTf) {
|
||||
queryTfIdf.set(term, tf * (idf.get(term) ?? 0))
|
||||
}
|
||||
|
||||
const queryCjkTokens = queryTokens.filter(t => isCjk(t[0] ?? ''))
|
||||
const queryAsciiTokens = queryTokens.filter(t => !isCjk(t[0] ?? ''))
|
||||
const queryLower = query.toLowerCase().replace(/[-_]/g, ' ')
|
||||
|
||||
const results: SearchExtraToolsResult[] = []
|
||||
for (const entry of index) {
|
||||
let score = cosineSimilarity(queryTfIdf, entry.tfVector)
|
||||
|
||||
if (queryCjkTokens.length > 0 && score > 0) {
|
||||
const matchingCjk = queryCjkTokens.filter(t => entry.tfVector.has(t))
|
||||
if (matchingCjk.length < CJK_MIN_BIGRAM_MATCHES) {
|
||||
const hasAsciiMatch = queryAsciiTokens.some(t => entry.tfVector.has(t))
|
||||
if (!hasAsciiMatch) score = 0
|
||||
}
|
||||
}
|
||||
|
||||
if (queryLower.includes(entry.normalizedName)) {
|
||||
score = Math.max(score, 0.75)
|
||||
}
|
||||
|
||||
if (score >= SEARCH_EXTRA_TOOLS_DISPLAY_MIN_SCORE) {
|
||||
results.push({
|
||||
name: entry.name,
|
||||
description: entry.description,
|
||||
searchHint: entry.searchHint,
|
||||
score,
|
||||
isMcp: entry.isMcp,
|
||||
isDeferred: entry.isDeferred,
|
||||
inputSchema: entry.inputSchema,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
results.sort((a, b) => b.score - a.score)
|
||||
return results.slice(0, limit)
|
||||
}
|
||||
|
||||
let cachedIndex: ToolIndexEntry[] | null = null
|
||||
let cachedToolNames: string | null = null
|
||||
|
||||
export async function getToolIndex(tools: Tools): Promise<ToolIndexEntry[]> {
|
||||
const currentKey = tools
|
||||
.map(t => t.name)
|
||||
.sort()
|
||||
.join(',')
|
||||
|
||||
if (cachedIndex && cachedToolNames === currentKey) {
|
||||
return cachedIndex
|
||||
}
|
||||
|
||||
cachedIndex = await buildToolIndex(tools)
|
||||
cachedToolNames = currentKey
|
||||
return cachedIndex
|
||||
}
|
||||
|
||||
export function clearToolIndexCache(): void {
|
||||
cachedIndex = null
|
||||
cachedToolNames = null
|
||||
logForDebugging('[search-extra-tools] index cache cleared')
|
||||
}
|
||||
@@ -209,7 +209,7 @@ const FIELD_WEIGHT = {
|
||||
allowedTools: 0.3,
|
||||
} as const
|
||||
|
||||
function computeWeightedTf(
|
||||
export function computeWeightedTf(
|
||||
fields: { tokens: string[]; weight: number }[],
|
||||
): Map<string, number> {
|
||||
const weighted = new Map<string, number>()
|
||||
@@ -227,7 +227,7 @@ function computeWeightedTf(
|
||||
return weighted
|
||||
}
|
||||
|
||||
function computeIdf(index: SkillIndexEntry[]): Map<string, number> {
|
||||
export function computeIdf(index: { tokens: string[] }[]): Map<string, number> {
|
||||
const df = new Map<string, number>()
|
||||
for (const entry of index) {
|
||||
const seen = new Set<string>()
|
||||
@@ -246,7 +246,7 @@ function computeIdf(index: SkillIndexEntry[]): Map<string, number> {
|
||||
return idf
|
||||
}
|
||||
|
||||
function cosineSimilarity(
|
||||
export function cosineSimilarity(
|
||||
queryTfIdf: Map<string, number>,
|
||||
docTfIdf: Map<string, number>,
|
||||
): number {
|
||||
|
||||
@@ -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