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

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

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

View File

@@ -523,7 +523,7 @@ async function runInputActionGates(
`visible in screenshots only, no clicks or typing.` +
(isBrowser
? ' Use the Claude-in-Chrome MCP for browser interaction (tools ' +
'named `mcp__Claude_in_Chrome__*`; load via ToolSearch if ' +
'named `mcp__Claude_in_Chrome__*`; load via SearchExtraTools if ' +
'deferred).'
: ' No interaction is permitted; ask the user to take any ' +
'actions in this app themselves.') +
@@ -1308,7 +1308,7 @@ function buildTierGuidanceMessage(tiered: TieredApp[]): string {
`typing). You can read what's on screen but cannot navigate, click, ` +
`or type into ${readBrowsers.length === 1 ? 'it' : 'them'}. For browser ` +
`interaction, use the Claude-in-Chrome MCP (tools named ` +
`\`mcp__Claude_in_Chrome__*\`; load via ToolSearch if deferred).`,
`\`mcp__Claude_in_Chrome__*\`; load via SearchExtraTools if deferred).`,
)
}

View File

@@ -29,7 +29,7 @@ export { SkillTool } from './tools/SkillTool/SkillTool.js'
export { TaskOutputTool } from './tools/TaskOutputTool/TaskOutputTool.js'
export { TaskStopTool } from './tools/TaskStopTool/TaskStopTool.js'
export { TodoWriteTool } from './tools/TodoWriteTool/TodoWriteTool.js'
export { ToolSearchTool } from './tools/ToolSearchTool/ToolSearchTool.js'
export { SearchExtraToolsTool } from './tools/SearchExtraToolsTool/SearchExtraToolsTool.js'
export { TungstenTool } from './tools/TungstenTool/TungstenTool.js'
export { WebFetchTool } from './tools/WebFetchTool/WebFetchTool.js'
export { WebSearchTool } from './tools/WebSearchTool/WebSearchTool.js'

View File

@@ -68,7 +68,7 @@ export const ExecuteTool = buildTool({
},
newMessages: [
createUserMessage({
content: `Tool "${input.tool_name}" not found. Use ToolSearch to discover available tools.`,
content: `Tool "${input.tool_name}" not found. Use SearchExtraTools to discover available tools.`,
}),
],
}

View File

@@ -29,12 +29,12 @@ mock.module('src/services/analytics/growthbook.js', () => ({
stopPeriodicGrowthBookRefresh: () => {},
}))
mock.module('src/utils/toolSearch.js', () => ({
isToolSearchEnabledOptimistic: () => true,
getAutoToolSearchCharThreshold: () => 100,
getToolSearchMode: () => 'tst' as const,
isToolSearchToolAvailable: async () => true,
isToolSearchEnabled: async () => true,
mock.module('src/utils/searchExtraTools.js', () => ({
isSearchExtraToolsEnabledOptimistic: () => true,
getAutoSearchExtraToolsCharThreshold: () => 100,
getSearchExtraToolsMode: () => 'tst' as const,
isSearchExtraToolsToolAvailable: async () => true,
isSearchExtraToolsEnabled: async () => true,
isToolReferenceBlock: () => false,
extractDiscoveredToolNames: () => new Set(),
isDeferredToolsDeltaEnabled: () => false,
@@ -42,7 +42,7 @@ mock.module('src/utils/toolSearch.js', () => ({
}))
mock.module('src/constants/tools.js', () => ({
CORE_TOOLS: new Set(['ExecuteExtraTool', 'ToolSearch']),
CORE_TOOLS: new Set(['ExecuteExtraTool', 'SearchExtraTools']),
}))
// Mock messages module

View File

@@ -1,19 +1,19 @@
import { EXECUTE_TOOL_NAME } from './constants.js'
export const DESCRIPTION =
'ExecuteExtraTool — a first-class core tool that is always loaded and available. Execute any deferred tool by name with parameters. Use it after discovering a tool via ToolSearch. This is NOT a remote or external tool — it runs locally with full permissions.'
'ExecuteExtraTool — a first-class core tool that is always loaded and available. Execute any deferred tool by name with parameters. Use it after discovering a tool via SearchExtraTools. This is NOT a remote or external tool — it runs locally with full permissions.'
export function getPrompt(): string {
return `ExecuteExtraTool — a first-class core tool, always loaded, always available in your tool list. Runs locally with full permissions — NOT a remote or external tool. You do NOT need to search for it.
This tool accepts a tool_name and params object, looks up the target tool in the global tool registry, and delegates execution to it. The target tool runs with the same permissions and capabilities as if it were called directly.
When to use: After ToolSearch discovers a deferred tool name, call this tool with {"tool_name": "<name>", "params": {...}} to invoke it immediately.
When to use: After SearchExtraTools discovers a deferred tool name, call this tool with {"tool_name": "<name>", "params": {...}} to invoke it immediately.
When NOT to use: For core tools already in your tool list (Read, Edit, Write, Bash, Glob, Grep, Agent, WebFetch, WebSearch, Skill, etc.) — call those directly.
Inputs:
- tool_name: The exact name of the target tool (string)
- params: The parameters to pass to the target tool (object)
If the tool is not found, an error message will be returned suggesting to use ToolSearch to discover available tools.`
If the tool is not found, an error message will be returned suggesting to use SearchExtraTools to discover available tools.`
}

View File

@@ -15,13 +15,24 @@ import {
import { logForDebugging } from 'src/utils/debug.js'
import { lazySchema } from 'src/utils/lazySchema.js'
import { escapeRegExp } from 'src/utils/stringUtils.js'
import { isToolSearchEnabledOptimistic } from 'src/utils/toolSearch.js'
import { getPrompt, isDeferredTool, TOOL_SEARCH_TOOL_NAME } from './prompt.js'
import { getToolIndex, searchTools } from 'src/services/toolSearch/toolIndex.js'
import type { ToolSearchResult } from 'src/services/toolSearch/toolIndex.js'
import { isSearchExtraToolsEnabledOptimistic } from 'src/utils/searchExtraTools.js'
import {
getPrompt,
isDeferredTool,
SEARCH_EXTRA_TOOLS_TOOL_NAME,
} from './prompt.js'
import {
getToolIndex,
searchTools,
} from 'src/services/searchExtraTools/toolIndex.js'
import type { SearchExtraToolsResult } from 'src/services/searchExtraTools/toolIndex.js'
const KEYWORD_WEIGHT = Number(process.env.TOOL_SEARCH_WEIGHT_KEYWORD ?? '0.4')
const TFIDF_WEIGHT = Number(process.env.TOOL_SEARCH_WEIGHT_TFIDF ?? '0.6')
const KEYWORD_WEIGHT = Number(
process.env.SEARCH_EXTRA_TOOLS_WEIGHT_KEYWORD ?? '0.4',
)
const TFIDF_WEIGHT = Number(
process.env.SEARCH_EXTRA_TOOLS_WEIGHT_TFIDF ?? '0.6',
)
export const inputSchema = lazySchema(() =>
z.object({
@@ -99,14 +110,14 @@ function maybeInvalidateCache(deferredTools: Tools): void {
const currentKey = getDeferredToolsCacheKey(deferredTools)
if (cachedDeferredToolNames !== currentKey) {
logForDebugging(
`ToolSearchTool: cache invalidated - deferred tools changed`,
`SearchExtraToolsTool: cache invalidated - deferred tools changed`,
)
getToolDescriptionMemoized.cache.clear?.()
cachedDeferredToolNames = currentKey
}
}
export function clearToolSearchDescriptionCache(): void {
export function clearSearchExtraToolsDescriptionCache(): void {
getToolDescriptionMemoized.cache.clear?.()
cachedDeferredToolNames = null
}
@@ -312,9 +323,9 @@ async function searchToolsWithKeywords(
.map(item => item.name)
}
export const ToolSearchTool = buildTool({
export const SearchExtraToolsTool = buildTool({
isEnabled() {
return isToolSearchEnabledOptimistic()
return isSearchExtraToolsEnabledOptimistic()
},
isConcurrencySafe() {
return true
@@ -322,7 +333,7 @@ export const ToolSearchTool = buildTool({
isReadOnly() {
return true
},
name: TOOL_SEARCH_TOOL_NAME,
name: SEARCH_EXTRA_TOOLS_TOOL_NAME,
maxResultSizeChars: 100_000,
async description() {
return getPrompt()
@@ -354,7 +365,7 @@ export const ToolSearchTool = buildTool({
matches: string[],
queryType: 'select' | 'keyword',
): void {
logEvent('tengu_tool_search_outcome', {
logEvent('tengu_search_extra_tools_outcome', {
query:
query as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
queryType:
@@ -398,7 +409,7 @@ export const ToolSearchTool = buildTool({
if (found.length === 0) {
logForDebugging(
`ToolSearchTool: select failed — none found: ${missing.join(', ')}`,
`SearchExtraToolsTool: select failed — none found: ${missing.join(', ')}`,
)
logSearchOutcome([], 'select')
const pendingServers = getPendingServerNames()
@@ -412,10 +423,10 @@ export const ToolSearchTool = buildTool({
if (missing.length > 0) {
logForDebugging(
`ToolSearchTool: partial select — found: ${found.join(', ')}, missing: ${missing.join(', ')}`,
`SearchExtraToolsTool: partial select — found: ${found.join(', ')}, missing: ${missing.join(', ')}`,
)
} else {
logForDebugging(`ToolSearchTool: selected ${found.join(', ')}`)
logForDebugging(`SearchExtraToolsTool: selected ${found.join(', ')}`)
}
logSearchOutcome(found, 'select')
return buildSearchResult(
@@ -493,7 +504,7 @@ export const ToolSearchTool = buildTool({
const alreadyLoaded = matches.filter(name => !deferredToolNames.has(name))
logForDebugging(
`ToolSearchTool: keyword search for "${query}", found ${matches.length} matches`,
`SearchExtraToolsTool: keyword search for "${query}", found ${matches.length} matches`,
)
logSearchOutcome(matches, 'keyword')
@@ -522,7 +533,7 @@ export const ToolSearchTool = buildTool({
return `"${input.query}"`
},
userFacingName() {
return 'ToolSearch'
return 'SearchExtraTools'
},
/**
* Returns a tool_result with text output guiding the model to use ExecuteExtraTool.
@@ -559,7 +570,7 @@ export const ToolSearchTool = buildTool({
return {
type: 'tool_result',
tool_use_id: toolUseID,
content: `No deferred tools found. ${alreadyLoadedNames.join(', ')} ${alreadyLoadedNames.length === 1 ? 'is' : 'are'} already loaded as core tool(s) — call directly, do NOT search for or wrap in ExecuteExtraTool. ToolSearch is only for discovering tools NOT already in your tool list.`,
content: `No deferred tools found. ${alreadyLoadedNames.join(', ')} ${alreadyLoadedNames.length === 1 ? 'is' : 'are'} already loaded as core tool(s) — call directly, do NOT search for or wrap in ExecuteExtraTool. SearchExtraTools is only for discovering tools NOT already in your tool list.`,
}
}

View File

@@ -28,12 +28,12 @@ mock.module('src/services/analytics/growthbook.js', () => ({
stopPeriodicGrowthBookRefresh: () => {},
}))
mock.module('src/utils/toolSearch.js', () => ({
isToolSearchEnabledOptimistic: () => true,
getAutoToolSearchCharThreshold: () => 100,
getToolSearchMode: () => 'tst' as const,
isToolSearchToolAvailable: async () => true,
isToolSearchEnabled: async () => true,
mock.module('src/utils/searchExtraTools.js', () => ({
isSearchExtraToolsEnabledOptimistic: () => true,
getAutoSearchExtraToolsCharThreshold: () => 100,
getSearchExtraToolsMode: () => 'tst' as const,
isSearchExtraToolsToolAvailable: async () => true,
isSearchExtraToolsEnabled: async () => true,
isToolReferenceBlock: () => false,
extractDiscoveredToolNames: () => new Set(),
isDeferredToolsDeltaEnabled: () => false,
@@ -41,11 +41,11 @@ mock.module('src/utils/toolSearch.js', () => ({
}))
mock.module('src/constants/tools.js', () => ({
CORE_TOOLS: new Set(['Read', 'Edit', 'ToolSearch', 'ExecuteExtraTool']),
CORE_TOOLS: new Set(['Read', 'Edit', 'SearchExtraTools', 'ExecuteExtraTool']),
}))
// Mock toolIndex module
type MockToolSearchResult = {
type MockSearchExtraToolsResult = {
name: string
description: string
searchHint: string | undefined
@@ -59,11 +59,11 @@ const mockSearchTools = mock(
_query: string,
_index: unknown,
_limit?: number,
): MockToolSearchResult[] => [],
): MockSearchExtraToolsResult[] => [],
)
const mockGetToolIndex = mock(async (_tools: unknown) => [])
mock.module('src/services/toolSearch/toolIndex.js', () => ({
mock.module('src/services/searchExtraTools/toolIndex.js', () => ({
getToolIndex: mockGetToolIndex,
searchTools: mockSearchTools,
}))
@@ -73,7 +73,7 @@ mock.module('src/services/analytics/index.js', () => ({
logEvent: () => {},
}))
const { ToolSearchTool } = await import('../ToolSearchTool.js')
const { SearchExtraToolsTool } = await import('../SearchExtraToolsTool.js')
function makeDeferredTool(name: string, desc: string = 'A tool') {
return {
@@ -100,7 +100,7 @@ function makeContext(tools: unknown[] = []) {
} as never
}
describe('ToolSearchTool search enhancements', () => {
describe('SearchExtraToolsTool search enhancements', () => {
test('discover: prefix triggers TF-IDF search and returns matches', async () => {
const mockTool = makeDeferredTool('CronCreate', 'Schedule cron jobs')
mockGetToolIndex.mockResolvedValueOnce([])
@@ -117,7 +117,7 @@ describe('ToolSearchTool search enhancements', () => {
])
const result: { data: { matches: string[] } } = await (
ToolSearchTool as any
SearchExtraToolsTool as any
).call(
{ query: 'discover:schedule cron job', max_results: 5 },
makeContext([mockTool]),
@@ -158,7 +158,7 @@ describe('ToolSearchTool search enhancements', () => {
])
const result: { data: { matches: string[] } } = await (
ToolSearchTool as any
SearchExtraToolsTool as any
).call(
{ query: 'tool B', max_results: 5 },
makeContext([toolA, toolB, toolC]),
@@ -190,7 +190,7 @@ describe('ToolSearchTool search enhancements', () => {
])
// mapToolResultToToolResultBlockParam always returns text, not tool_reference
const blockParam = ToolSearchTool.mapToolResultToToolResultBlockParam(
const blockParam = SearchExtraToolsTool.mapToolResultToToolResultBlockParam(
{ matches: ['TestTool'], query: 'test', total_deferred_tools: 1 },
'tool-use-123',
{ mainLoopModel: 'claude-3-haiku-20240307' },
@@ -202,7 +202,7 @@ describe('ToolSearchTool search enhancements', () => {
})
test('text output works for any model without distinction', async () => {
const blockParam = ToolSearchTool.mapToolResultToToolResultBlockParam(
const blockParam = SearchExtraToolsTool.mapToolResultToToolResultBlockParam(
{ matches: ['TestTool'], query: 'test', total_deferred_tools: 1 },
'tool-use-123',
{ mainLoopModel: 'claude-sonnet-4-20250514' },
@@ -214,7 +214,7 @@ describe('ToolSearchTool search enhancements', () => {
})
test('backwards compatible without context parameter', async () => {
const blockParam = ToolSearchTool.mapToolResultToToolResultBlockParam(
const blockParam = SearchExtraToolsTool.mapToolResultToToolResultBlockParam(
{ matches: ['TestTool'], query: 'test', total_deferred_tools: 1 },
'tool-use-123',
)
@@ -225,7 +225,7 @@ describe('ToolSearchTool search enhancements', () => {
})
test('empty results return helpful message', async () => {
const blockParam = ToolSearchTool.mapToolResultToToolResultBlockParam(
const blockParam = SearchExtraToolsTool.mapToolResultToToolResultBlockParam(
{ matches: [], query: 'nonexistent', total_deferred_tools: 5 },
'tool-use-123',
)

View File

@@ -0,0 +1 @@
export const SEARCH_EXTRA_TOOLS_TOOL_NAME = 'SearchExtraTools'

View File

@@ -2,16 +2,16 @@ import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/grow
import type { Tool } from 'src/Tool.js'
import { CORE_TOOLS } from 'src/constants/tools.js'
export { TOOL_SEARCH_TOOL_NAME } from './constants.js'
export { SEARCH_EXTRA_TOOLS_TOOL_NAME } from './constants.js'
import { TOOL_SEARCH_TOOL_NAME } from './constants.js'
import { SEARCH_EXTRA_TOOLS_TOOL_NAME } from './constants.js'
const PROMPT_HEAD = `Search for deferred tools by name or keyword. LOW PRIORITY — only use this tool when no core tool can accomplish the task. Core tools (Read, Edit, Write, Bash, Glob, Grep, Agent, WebFetch, WebSearch, Skill) are always available and should be used directly. This tool is for discovering additional capabilities like MCP tools, cron scheduling, worktree management, agent teams (TeamCreate, TeamDelete, SendMessage), etc.
`
// Matches isDeferredToolsDeltaEnabled in toolSearch.ts (not imported —
// toolSearch.ts imports from this file). When enabled: tools announced
// Matches isDeferredToolsDeltaEnabled in searchExtraTools.ts (not imported —
// searchExtraTools.ts imports from this file). When enabled: tools announced
// via system-reminder attachments. When disabled: prepended
// <available-deferred-tools> block (pre-gate behavior).
function getToolLocationHint(): string {
@@ -25,7 +25,7 @@ function getToolLocationHint(): string {
const PROMPT_TAIL = ` Returns matching tool names.
ExecuteExtraTool is a first-class tool that is always available you do NOT need to search for it. After this search returns tool names, call ExecuteExtraTool directly with {"tool_name": "<returned_name>", "params": {...}} to invoke any deferred tool.
IMPORTANT: ExecuteExtraTool is always available in your tool list. After this search returns tool names, you MUST call ExecuteExtraTool with {"tool_name": "<returned_name>", "params": {...}} to invoke the deferred tool. This is the ONLY way to execute deferred tools do not read source code or analyze whether the tool is callable, just use ExecuteExtraTool directly.
Query forms:
- "select:CronCreate,Snip" fetch these exact tools by name
@@ -34,11 +34,11 @@ Query forms:
- "+slack send" require "slack" in the name, rank by remaining terms`
/**
* Check if a tool should be deferred (requires ToolSearch to load).
* Check if a tool should be deferred (requires SearchExtraTools to load).
* A tool is deferred if it is NOT in CORE_TOOLS and does NOT have alwaysLoad: true.
* Core tools are always loaded never deferred.
* All other tools (non-core built-in + all MCP tools) are deferred
* and must be discovered via ToolSearchTool / ExecuteExtraTool.
* and must be discovered via SearchExtraToolsTool / ExecuteExtraTool.
*/
export function isDeferredTool(tool: Tool): boolean {
// Explicit opt-out via _meta['anthropic/alwaysLoad']

View File

@@ -1 +0,0 @@
export const TOOL_SEARCH_TOOL_NAME = 'ToolSearch'

View File

@@ -179,10 +179,10 @@ export const WebFetchTool = buildTool({
}
},
async prompt(_options) {
// Always include the auth warning regardless of whether ToolSearch is
// Always include the auth warning regardless of whether SearchExtraTools is
// currently in the tools list. Conditionally toggling this prefix based
// on ToolSearch availability caused the tool description to flicker
// between SDK query() calls (when ToolSearch enablement varies due to
// on SearchExtraTools availability caused the tool description to flicker
// between SDK query() calls (when SearchExtraTools enablement varies due to
// MCP tool count thresholds), invalidating the Anthropic API prompt
// cache on each toggle — two consecutive cache misses per flicker event.
return `IMPORTANT: WebFetch WILL FAIL for authenticated or private URLs. Before using this tool, check if the URL points to an authenticated service (e.g. Google Docs, Confluence, Jira, GitHub). If so, look for a specialized MCP tool that provides authenticated access.