Skip to content

Commit 7e2b8e8

Browse files
Merge pull request #442 from claude-code-best/feature/tool_search
feat: 支持 SearchExtraTools 能力以替代 Tool Search
2 parents df8c4f4 + 547ce9e commit 7e2b8e8

78 files changed

Lines changed: 4981 additions & 785 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ bun run docs:dev
123123

124124
- **`src/Tool.ts`** — Tool interface definition (`Tool` type) and utilities (`findToolByName`, `toolMatchesName`).
125125
- **`src/tools.ts`** — Tool registry. Assembles the tool list; tools are imported from `@claude-code-best/builtin-tools` package. Some tools are conditionally loaded via `feature()` flags or `process.env.USER_TYPE`.
126+
- **`src/constants/tools.ts`**`CORE_TOOLS` 白名单常量(约 29 个核心工具名),用于 `isDeferredTool` 白名单制判定。
126127
- **`packages/builtin-tools/src/tools/`** — 59 个子目录(含 shared/testing 等工具目录),通过 `@claude-code-best/builtin-tools` 包导出。主要分类:
127128
- **文件操作**: FileEditTool, FileReadTool, FileWriteTool, GlobTool, GrepTool
128129
- **Shell/执行**: BashTool, PowerShellTool, REPLTool
@@ -132,6 +133,7 @@ bun run docs:dev
132133
- **调度**: CronCreateTool, CronDeleteTool, CronListTool
133134
- **其他**: LSPTool, ConfigTool, SkillTool, EnterWorktreeTool, ExitWorktreeTool 等
134135
- **`src/tools/shared/`** / **`packages/builtin-tools/src/tools/shared/`** — Tool 共享工具函数。
136+
- **`src/services/searchExtraTools/`** — TF-IDF 工具索引模块(`toolIndex.ts`),为延迟工具提供语义搜索能力。复用 `localSearch.ts` 的 TF-IDF 算法函数(`computeWeightedTf``computeIdf``cosineSimilarity` 已导出)。修改这些函数时需同步检查工具索引测试。`SearchExtraToolsTool.mapToolResultToToolResultBlockParam` 新增可选第三个参数 `context?: { mainLoopModel?: string }`,用于判断当前模型是否支持 `tool_reference`。不支持时回退到文本输出,引导模型使用 ExecuteTool。调用方(`src/services/api/claude.ts` 的 tool_result 处理逻辑)需传入 context 参数。`prefetch.ts``extractQueryFromMessages` 复用了 `skillSearch/prefetch.ts` 的同名导出函数,修改 skill prefetch 的该函数时需同步检查工具预取行为。工具预取使用独立的 `discoveredToolsThisSession` Set,与 skill prefetch 的去重集合互不影响。
135137

136138
### UI Layer (Ink)
137139

docs/design/tool-search-design-guide.md

Lines changed: 323 additions & 0 deletions
Large diffs are not rendered by default.

packages/@ant/computer-use-mcp/src/toolCalls.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -523,7 +523,7 @@ async function runInputActionGates(
523523
`visible in screenshots only, no clicks or typing.` +
524524
(isBrowser
525525
? ' Use the Claude-in-Chrome MCP for browser interaction (tools ' +
526-
'named `mcp__Claude_in_Chrome__*`; load via ToolSearch if ' +
526+
'named `mcp__Claude_in_Chrome__*`; load via SearchExtraTools if ' +
527527
'deferred).'
528528
: ' No interaction is permitted; ask the user to take any ' +
529529
'actions in this app themselves.') +
@@ -1308,7 +1308,7 @@ function buildTierGuidanceMessage(tiered: TieredApp[]): string {
13081308
`typing). You can read what's on screen but cannot navigate, click, ` +
13091309
`or type into ${readBrowsers.length === 1 ? 'it' : 'them'}. For browser ` +
13101310
`interaction, use the Claude-in-Chrome MCP (tools named ` +
1311-
`\`mcp__Claude_in_Chrome__*\`; load via ToolSearch if deferred).`,
1311+
`\`mcp__Claude_in_Chrome__*\`; load via SearchExtraTools if deferred).`,
13121312
)
13131313
}
13141314

packages/builtin-tools/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export { SkillTool } from './tools/SkillTool/SkillTool.js'
2929
export { TaskOutputTool } from './tools/TaskOutputTool/TaskOutputTool.js'
3030
export { TaskStopTool } from './tools/TaskStopTool/TaskStopTool.js'
3131
export { TodoWriteTool } from './tools/TodoWriteTool/TodoWriteTool.js'
32-
export { ToolSearchTool } from './tools/ToolSearchTool/ToolSearchTool.js'
32+
export { SearchExtraToolsTool } from './tools/SearchExtraToolsTool/SearchExtraToolsTool.js'
3333
export { TungstenTool } from './tools/TungstenTool/TungstenTool.js'
3434
export { WebFetchTool } from './tools/WebFetchTool/WebFetchTool.js'
3535
export { WebSearchTool } from './tools/WebSearchTool/WebSearchTool.js'

packages/builtin-tools/src/tools/BriefTool/BriefTool.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { buildTool, type ToolDef } from 'src/Tool.js'
88
import { isEnvTruthy } from 'src/utils/envUtils.js'
99
import { lazySchema } from 'src/utils/lazySchema.js'
1010
import { plural } from 'src/utils/stringUtils.js'
11+
import { isBridgeEnabled } from 'src/bridge/bridgeEnabled.js'
1112
import { resolveAttachments, validateAttachmentPaths } from './attachments.js'
1213
import {
1314
BRIEF_TOOL_NAME,
@@ -149,7 +150,7 @@ export const BriefTool = buildTool({
149150
return outputSchema()
150151
},
151152
isEnabled() {
152-
return isBriefEnabled()
153+
return isBridgeEnabled()
153154
},
154155
isConcurrencySafe() {
155156
return true
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { z } from 'zod/v4'
2+
import {
3+
buildTool,
4+
findToolByName,
5+
type Tool,
6+
type ToolDef,
7+
type ToolUseContext,
8+
type ToolResult,
9+
type Tools,
10+
} from 'src/Tool.js'
11+
import { lazySchema } from 'src/utils/lazySchema.js'
12+
import { createUserMessage } from 'src/utils/messages.js'
13+
import { DESCRIPTION, getPrompt } from './prompt.js'
14+
import { EXECUTE_TOOL_NAME } from './constants.js'
15+
16+
export const inputSchema = lazySchema(() =>
17+
z.object({
18+
tool_name: z
19+
.string()
20+
.describe(
21+
'The exact name of the target tool to execute (e.g., "CronCreate", "mcp__server__action")',
22+
),
23+
params: z
24+
.record(z.string(), z.unknown())
25+
.describe('The parameters to pass to the target tool'),
26+
}),
27+
)
28+
type InputSchema = ReturnType<typeof inputSchema>
29+
30+
export const outputSchema = lazySchema(() =>
31+
z.object({
32+
result: z.unknown(),
33+
tool_name: z.string(),
34+
}),
35+
)
36+
type OutputSchema = ReturnType<typeof outputSchema>
37+
38+
export type Output = z.infer<OutputSchema>
39+
40+
export const ExecuteTool = buildTool({
41+
name: EXECUTE_TOOL_NAME,
42+
searchHint: 'execute run invoke call a deferred tool by name with parameters',
43+
maxResultSizeChars: 100_000,
44+
isConcurrencySafe() {
45+
return false
46+
},
47+
get inputSchema(): InputSchema {
48+
return inputSchema()
49+
},
50+
get outputSchema(): OutputSchema {
51+
return outputSchema()
52+
},
53+
async description() {
54+
return DESCRIPTION
55+
},
56+
async prompt() {
57+
return getPrompt()
58+
},
59+
async call(input, context, canUseTool, parentMessage, onProgress) {
60+
const tools: Tools = context.options.tools ?? []
61+
62+
const targetTool = findToolByName(tools, input.tool_name)
63+
if (!targetTool) {
64+
return {
65+
data: {
66+
result: null,
67+
tool_name: input.tool_name,
68+
},
69+
newMessages: [
70+
createUserMessage({
71+
content: `Tool "${input.tool_name}" not found. Use SearchExtraTools to discover available tools.`,
72+
}),
73+
],
74+
}
75+
}
76+
77+
// Check if the target tool is currently enabled
78+
if (!targetTool.isEnabled()) {
79+
return {
80+
data: {
81+
result: null,
82+
tool_name: input.tool_name,
83+
},
84+
newMessages: [
85+
createUserMessage({
86+
content: `工具 "${input.tool_name}" 当前不可用:Remote Control 未连接。`,
87+
}),
88+
],
89+
}
90+
}
91+
92+
// Check permissions on the target tool
93+
const permResult = await targetTool.checkPermissions?.(
94+
input.params as Record<string, unknown>,
95+
context,
96+
)
97+
if (permResult && permResult.behavior === 'deny') {
98+
return {
99+
data: {
100+
result: null,
101+
tool_name: input.tool_name,
102+
},
103+
newMessages: [
104+
createUserMessage({
105+
content: `Permission denied for tool "${input.tool_name}": ${permResult.message ?? 'Permission denied'}`,
106+
}),
107+
],
108+
}
109+
}
110+
111+
// Delegate execution to the target tool
112+
const targetResult: ToolResult<unknown> = await targetTool.call(
113+
input.params as Record<string, unknown>,
114+
context,
115+
canUseTool,
116+
parentMessage,
117+
onProgress,
118+
)
119+
120+
return {
121+
...targetResult,
122+
data: {
123+
result: targetResult.data,
124+
tool_name: input.tool_name,
125+
},
126+
}
127+
},
128+
async checkPermissions() {
129+
return {
130+
behavior: 'passthrough',
131+
message: 'ExecuteExtraTool delegates permission to the target tool.',
132+
}
133+
},
134+
renderToolUseMessage(input) {
135+
return `Executing ${input.tool_name}...`
136+
},
137+
userFacingName() {
138+
return 'ExecuteExtraTool'
139+
},
140+
mapToolResultToToolResultBlockParam(content, toolUseID) {
141+
return {
142+
tool_use_id: toolUseID,
143+
type: 'tool_result',
144+
content: JSON.stringify(content),
145+
}
146+
},
147+
} satisfies ToolDef<InputSchema, Output>)
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { describe, test, expect } from 'bun:test'
2+
import { mock } from 'bun:test'
3+
import { logMock } from '../../../../../../tests/mocks/log'
4+
import { debugMock } from '../../../../../../tests/mocks/debug'
5+
6+
mock.module('src/utils/log.ts', logMock)
7+
mock.module('src/utils/debug.ts', debugMock)
8+
9+
// Mock all heavy dependencies before importing ExecuteTool
10+
mock.module('src/services/analytics/growthbook.js', () => ({
11+
getFeatureValue_CACHED_MAY_BE_STALE: () => false,
12+
checkStatsigFeatureGate_CACHED_MAY_BE_STALE: () => false,
13+
getFeatureValue_DEPRECATED: async () => undefined,
14+
getFeatureValue_CACHED_WITH_REFRESH: async () => undefined,
15+
hasGrowthBookEnvOverride: () => false,
16+
getAllGrowthBookFeatures: () => ({}),
17+
getGrowthBookConfigOverrides: () => ({}),
18+
setGrowthBookConfigOverride: () => {},
19+
clearGrowthBookConfigOverrides: () => {},
20+
getApiBaseUrlHost: () => undefined,
21+
onGrowthBookRefresh: () => {},
22+
initializeGrowthBook: async () => {},
23+
checkSecurityRestrictionGate: async () => false,
24+
checkGate_CACHED_OR_BLOCKING: async () => false,
25+
refreshGrowthBookAfterAuthChange: () => {},
26+
resetGrowthBook: () => {},
27+
refreshGrowthBookFeatures: async () => {},
28+
setupPeriodicGrowthBookRefresh: () => {},
29+
stopPeriodicGrowthBookRefresh: () => {},
30+
}))
31+
32+
mock.module('src/utils/searchExtraTools.js', () => ({
33+
isSearchExtraToolsEnabledOptimistic: () => true,
34+
getAutoSearchExtraToolsCharThreshold: () => 100,
35+
getSearchExtraToolsMode: () => 'tst' as const,
36+
isSearchExtraToolsToolAvailable: async () => true,
37+
isSearchExtraToolsEnabled: async () => true,
38+
isToolReferenceBlock: () => false,
39+
extractDiscoveredToolNames: () => new Set(),
40+
isDeferredToolsDeltaEnabled: () => false,
41+
getDeferredToolsDelta: () => null,
42+
}))
43+
44+
mock.module('src/constants/tools.js', () => ({
45+
CORE_TOOLS: new Set(['ExecuteExtraTool', 'SearchExtraTools']),
46+
}))
47+
48+
// Mock messages module
49+
mock.module('src/utils/messages.js', () => ({
50+
createUserMessage: ({ content }: { content: string }) => ({
51+
type: 'user' as const,
52+
content,
53+
uuid: 'test-uuid',
54+
}),
55+
}))
56+
57+
const { ExecuteTool } = await import('../ExecuteTool.js')
58+
const { EXECUTE_TOOL_NAME } = await import('../constants.js')
59+
60+
function makeContext(tools: unknown[] = []) {
61+
return {
62+
options: {
63+
tools,
64+
},
65+
cwd: '/tmp',
66+
sessionId: 'test',
67+
} as never
68+
}
69+
70+
function makeMockTool(name: string, callResult: unknown = 'ok') {
71+
return {
72+
name,
73+
call: async () => ({ data: callResult }),
74+
checkPermissions: async () => ({ behavior: 'allow' as const }),
75+
prompt: async () => `Description for ${name}`,
76+
description: async () => `Description for ${name}`,
77+
inputSchema: {},
78+
isEnabled: () => true,
79+
isConcurrencySafe: () => true,
80+
isReadOnly: () => false,
81+
isMcp: false,
82+
alwaysLoad: undefined,
83+
shouldDefer: undefined,
84+
searchHint: '',
85+
userFacingName: () => name,
86+
renderToolUseMessage: () => `Running ${name}`,
87+
mapToolResultToToolResultBlockParam: (content: unknown, id: string) => ({
88+
tool_use_id: id,
89+
type: 'tool_result',
90+
content,
91+
}),
92+
}
93+
}
94+
95+
describe('ExecuteTool', () => {
96+
test('executes a target tool by name', async () => {
97+
const mockTarget = makeMockTool('TestTool', { result: 'success' })
98+
const ctx = makeContext([mockTarget])
99+
100+
const result = await ExecuteTool.call(
101+
{ tool_name: 'TestTool', params: {} },
102+
ctx,
103+
async () => ({ behavior: 'allow' }),
104+
{ type: 'assistant', content: [], uuid: 'msg1' } as never,
105+
undefined,
106+
)
107+
108+
expect(result.data).toEqual({
109+
result: { result: 'success' },
110+
tool_name: 'TestTool',
111+
})
112+
})
113+
114+
test('returns error when tool not found', async () => {
115+
const ctx = makeContext([])
116+
117+
const result = await ExecuteTool.call(
118+
{ tool_name: 'NonexistentTool', params: {} },
119+
ctx,
120+
async () => ({ behavior: 'allow' }),
121+
{ type: 'assistant', content: [], uuid: 'msg1' } as never,
122+
undefined,
123+
)
124+
125+
expect(result.data).toEqual({
126+
result: null,
127+
tool_name: 'NonexistentTool',
128+
})
129+
expect(result.newMessages).toBeDefined()
130+
expect(result.newMessages!.length).toBeGreaterThan(0)
131+
})
132+
133+
test('returns permission denied when target denies', async () => {
134+
const mockTarget = makeMockTool('SecretTool', 'secret')
135+
mockTarget.checkPermissions = async () =>
136+
({
137+
behavior: 'deny' as const,
138+
message: 'Access denied',
139+
}) as never
140+
const ctx = makeContext([mockTarget])
141+
142+
const result = await ExecuteTool.call(
143+
{ tool_name: 'SecretTool', params: {} },
144+
ctx,
145+
async () => ({ behavior: 'allow' }),
146+
{ type: 'assistant', content: [], uuid: 'msg1' } as never,
147+
undefined,
148+
)
149+
150+
expect(result.data).toEqual({
151+
result: null,
152+
tool_name: 'SecretTool',
153+
})
154+
expect(result.newMessages).toBeDefined()
155+
})
156+
157+
test('has correct name', () => {
158+
expect(ExecuteTool.name).toBe(EXECUTE_TOOL_NAME)
159+
})
160+
161+
test('searchHint contains keywords', () => {
162+
expect(ExecuteTool.searchHint).toContain('execute')
163+
expect(ExecuteTool.searchHint).toContain('tool')
164+
})
165+
})

0 commit comments

Comments
 (0)