mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-19 06:45:50 +00:00
feat: 实现 Tool Search 基础设施层(CORE_TOOLS 白名单 + TF-IDF 索引 + ExecuteTool + 搜索增强)
- 新增 CORE_TOOLS 白名单常量(31 个核心工具),重构 isDeferredTool 为白名单制判定 - 新建 TF-IDF 工具索引模块(toolIndex.ts),复用 localSearch.ts 算法函数 - 新建 ExecuteTool 跨 API provider 统一工具执行入口 - 增强 ToolSearchTool:TF-IDF 搜索路径、discover: 模式、并行搜索合并、文本模式回退 - 新增 27 个单元测试,precheck 零错误通过(4108 tests pass) Co-Authored-By: glm-5.1[1m] <zai-org@claude-code-best.win>
This commit is contained in:
132
packages/builtin-tools/src/tools/ExecuteTool/ExecuteTool.ts
Normal file
132
packages/builtin-tools/src/tools/ExecuteTool/ExecuteTool.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { z } from 'zod/v4'
|
||||
import {
|
||||
buildTool,
|
||||
findToolByName,
|
||||
type Tool,
|
||||
type ToolDef,
|
||||
type ToolUseContext,
|
||||
type ToolResult,
|
||||
type Tools,
|
||||
} from 'src/Tool.js'
|
||||
import { lazySchema } from 'src/utils/lazySchema.js'
|
||||
import { createUserMessage } from 'src/utils/messages.js'
|
||||
import { DESCRIPTION, getPrompt } from './prompt.js'
|
||||
import { EXECUTE_TOOL_NAME } from './constants.js'
|
||||
|
||||
export const inputSchema = lazySchema(() =>
|
||||
z.object({
|
||||
tool_name: z
|
||||
.string()
|
||||
.describe(
|
||||
'The exact name of the target tool to execute (e.g., "CronCreate", "mcp__server__action")',
|
||||
),
|
||||
params: z
|
||||
.record(z.string(), z.unknown())
|
||||
.describe('The parameters to pass to the target tool'),
|
||||
}),
|
||||
)
|
||||
type InputSchema = ReturnType<typeof inputSchema>
|
||||
|
||||
export const outputSchema = lazySchema(() =>
|
||||
z.object({
|
||||
result: z.unknown(),
|
||||
tool_name: z.string(),
|
||||
}),
|
||||
)
|
||||
type OutputSchema = ReturnType<typeof outputSchema>
|
||||
|
||||
export type Output = z.infer<OutputSchema>
|
||||
|
||||
export const ExecuteTool = buildTool({
|
||||
name: EXECUTE_TOOL_NAME,
|
||||
searchHint: 'execute run invoke call a deferred tool by name with parameters',
|
||||
maxResultSizeChars: 100_000,
|
||||
isConcurrencySafe() {
|
||||
return false
|
||||
},
|
||||
get inputSchema(): InputSchema {
|
||||
return inputSchema()
|
||||
},
|
||||
get outputSchema(): OutputSchema {
|
||||
return outputSchema()
|
||||
},
|
||||
async description() {
|
||||
return DESCRIPTION
|
||||
},
|
||||
async prompt() {
|
||||
return getPrompt()
|
||||
},
|
||||
async call(input, context, canUseTool, parentMessage, onProgress) {
|
||||
const tools: Tools = context.options.tools ?? []
|
||||
|
||||
const targetTool = findToolByName(tools, input.tool_name)
|
||||
if (!targetTool) {
|
||||
return {
|
||||
data: {
|
||||
result: null,
|
||||
tool_name: input.tool_name,
|
||||
},
|
||||
newMessages: [
|
||||
createUserMessage({
|
||||
content: `Tool "${input.tool_name}" not found. Use ToolSearch to discover available tools.`,
|
||||
}),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
// Check permissions on the target tool
|
||||
const permResult = await targetTool.checkPermissions?.(
|
||||
input.params as Record<string, unknown>,
|
||||
context,
|
||||
)
|
||||
if (permResult && permResult.behavior === 'deny') {
|
||||
return {
|
||||
data: {
|
||||
result: null,
|
||||
tool_name: input.tool_name,
|
||||
},
|
||||
newMessages: [
|
||||
createUserMessage({
|
||||
content: `Permission denied for tool "${input.tool_name}": ${permResult.message ?? 'Permission denied'}`,
|
||||
}),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
// Delegate execution to the target tool
|
||||
const targetResult: ToolResult<unknown> = await targetTool.call(
|
||||
input.params as Record<string, unknown>,
|
||||
context,
|
||||
canUseTool,
|
||||
parentMessage,
|
||||
onProgress,
|
||||
)
|
||||
|
||||
return {
|
||||
...targetResult,
|
||||
data: {
|
||||
result: targetResult.data,
|
||||
tool_name: input.tool_name,
|
||||
},
|
||||
}
|
||||
},
|
||||
async checkPermissions() {
|
||||
return {
|
||||
behavior: 'passthrough',
|
||||
message: 'ExecuteTool delegates permission to the target tool.',
|
||||
}
|
||||
},
|
||||
renderToolUseMessage(input) {
|
||||
return `Executing ${input.tool_name}...`
|
||||
},
|
||||
userFacingName() {
|
||||
return 'ExecuteTool'
|
||||
},
|
||||
mapToolResultToToolResultBlockParam(content, toolUseID) {
|
||||
return {
|
||||
tool_use_id: toolUseID,
|
||||
type: 'tool_result',
|
||||
content: JSON.stringify(content),
|
||||
}
|
||||
},
|
||||
} satisfies ToolDef<InputSchema, Output>)
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, test, expect } 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 all heavy dependencies before importing ExecuteTool
|
||||
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: () => {},
|
||||
}))
|
||||
|
||||
mock.module('src/utils/toolSearch.js', () => ({
|
||||
isToolSearchEnabledOptimistic: () => true,
|
||||
getAutoToolSearchCharThreshold: () => 100,
|
||||
getToolSearchMode: () => 'tst' as const,
|
||||
modelSupportsToolReference: () => true,
|
||||
isToolSearchToolAvailable: async () => true,
|
||||
isToolSearchEnabled: async () => true,
|
||||
isToolReferenceBlock: () => false,
|
||||
extractDiscoveredToolNames: () => new Set(),
|
||||
isDeferredToolsDeltaEnabled: () => false,
|
||||
getDeferredToolsDelta: () => null,
|
||||
}))
|
||||
|
||||
mock.module('src/constants/tools.js', () => ({
|
||||
CORE_TOOLS: new Set(['ExecuteTool', 'ToolSearch']),
|
||||
}))
|
||||
|
||||
// Mock messages module
|
||||
mock.module('src/utils/messages.js', () => ({
|
||||
createUserMessage: ({ content }: { content: string }) => ({
|
||||
type: 'user' as const,
|
||||
content,
|
||||
uuid: 'test-uuid',
|
||||
}),
|
||||
}))
|
||||
|
||||
const { ExecuteTool } = await import('../ExecuteTool.js')
|
||||
const { EXECUTE_TOOL_NAME } = await import('../constants.js')
|
||||
|
||||
function makeContext(tools: unknown[] = []) {
|
||||
return {
|
||||
options: {
|
||||
tools,
|
||||
},
|
||||
cwd: '/tmp',
|
||||
sessionId: 'test',
|
||||
} as never
|
||||
}
|
||||
|
||||
function makeMockTool(name: string, callResult: unknown = 'ok') {
|
||||
return {
|
||||
name,
|
||||
call: async () => ({ data: callResult }),
|
||||
checkPermissions: async () => ({ behavior: 'allow' as const }),
|
||||
prompt: async () => `Description for ${name}`,
|
||||
description: async () => `Description for ${name}`,
|
||||
inputSchema: {},
|
||||
isEnabled: () => true,
|
||||
isConcurrencySafe: () => true,
|
||||
isReadOnly: () => false,
|
||||
isMcp: false,
|
||||
alwaysLoad: undefined,
|
||||
shouldDefer: undefined,
|
||||
searchHint: '',
|
||||
userFacingName: () => name,
|
||||
renderToolUseMessage: () => `Running ${name}`,
|
||||
mapToolResultToToolResultBlockParam: (content: unknown, id: string) => ({
|
||||
tool_use_id: id,
|
||||
type: 'tool_result',
|
||||
content,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
describe('ExecuteTool', () => {
|
||||
test('executes a target tool by name', async () => {
|
||||
const mockTarget = makeMockTool('TestTool', { result: 'success' })
|
||||
const ctx = makeContext([mockTarget])
|
||||
|
||||
const result = await ExecuteTool.call(
|
||||
{ tool_name: 'TestTool', params: {} },
|
||||
ctx,
|
||||
async () => ({ behavior: 'allow' }),
|
||||
{ type: 'assistant', content: [], uuid: 'msg1' } as never,
|
||||
undefined,
|
||||
)
|
||||
|
||||
expect(result.data).toEqual({
|
||||
result: { result: 'success' },
|
||||
tool_name: 'TestTool',
|
||||
})
|
||||
})
|
||||
|
||||
test('returns error when tool not found', async () => {
|
||||
const ctx = makeContext([])
|
||||
|
||||
const result = await ExecuteTool.call(
|
||||
{ tool_name: 'NonexistentTool', params: {} },
|
||||
ctx,
|
||||
async () => ({ behavior: 'allow' }),
|
||||
{ type: 'assistant', content: [], uuid: 'msg1' } as never,
|
||||
undefined,
|
||||
)
|
||||
|
||||
expect(result.data).toEqual({
|
||||
result: null,
|
||||
tool_name: 'NonexistentTool',
|
||||
})
|
||||
expect(result.newMessages).toBeDefined()
|
||||
expect(result.newMessages!.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('returns permission denied when target denies', async () => {
|
||||
const mockTarget = makeMockTool('SecretTool', 'secret')
|
||||
mockTarget.checkPermissions = async () =>
|
||||
({
|
||||
behavior: 'deny' as const,
|
||||
message: 'Access denied',
|
||||
}) as never
|
||||
const ctx = makeContext([mockTarget])
|
||||
|
||||
const result = await ExecuteTool.call(
|
||||
{ tool_name: 'SecretTool', params: {} },
|
||||
ctx,
|
||||
async () => ({ behavior: 'allow' }),
|
||||
{ type: 'assistant', content: [], uuid: 'msg1' } as never,
|
||||
undefined,
|
||||
)
|
||||
|
||||
expect(result.data).toEqual({
|
||||
result: null,
|
||||
tool_name: 'SecretTool',
|
||||
})
|
||||
expect(result.newMessages).toBeDefined()
|
||||
})
|
||||
|
||||
test('has correct name', () => {
|
||||
expect(ExecuteTool.name).toBe(EXECUTE_TOOL_NAME)
|
||||
})
|
||||
|
||||
test('searchHint contains keywords', () => {
|
||||
expect(ExecuteTool.searchHint).toContain('execute')
|
||||
expect(ExecuteTool.searchHint).toContain('tool')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* ExecuteTool.test.ts
|
||||
*
|
||||
* Thin subprocess wrapper that runs the actual tests in an isolated bun:test
|
||||
* process. This prevents mock.module() leaks from other test files
|
||||
* (e.g., agentToolUtils.test.ts mocking src/Tool.js) from affecting
|
||||
* ExecuteTool's tests.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { resolve, relative } from 'path'
|
||||
|
||||
const PROJECT_ROOT = resolve(__dirname, '..', '..', '..', '..', '..')
|
||||
const RUNNER_ABS = resolve(__dirname, 'ExecuteTool.runner.ts')
|
||||
const RUNNER_REL = './' + relative(PROJECT_ROOT, RUNNER_ABS).replace(/\\/g, '/')
|
||||
|
||||
describe('ExecuteTool', () => {
|
||||
test('runs all ExecuteTool 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(
|
||||
`ExecuteTool test subprocess failed (exit ${code}):\n${output}`,
|
||||
)
|
||||
}
|
||||
}, 60_000)
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export const EXECUTE_TOOL_NAME = 'ExecuteTool'
|
||||
16
packages/builtin-tools/src/tools/ExecuteTool/prompt.ts
Normal file
16
packages/builtin-tools/src/tools/ExecuteTool/prompt.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { EXECUTE_TOOL_NAME } from './constants.js'
|
||||
|
||||
export const DESCRIPTION =
|
||||
'Execute a deferred tool by name with parameters. Use this after discovering a tool via ToolSearch.'
|
||||
|
||||
export function getPrompt(): string {
|
||||
return `Execute a deferred tool by name. This tool accepts a tool_name and params object, looks up the target tool in the global tool registry, and delegates execution to it.
|
||||
|
||||
Use this tool after discovering a deferred tool via ToolSearch. The tool_name must match the exact name returned by ToolSearch (e.g., "CronCreate", "mcp__server__action").
|
||||
|
||||
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.`
|
||||
}
|
||||
Reference in New Issue
Block a user