fix(search-extra-tools): return input schema in discover mode results

## Bug 详情
SearchExtraTools 的 discover: 查询模式计算了包含 description 和 inputSchema
的文本,但调用 buildSearchResult() 时只传入工具名数组,schema 信息被丢弃。
LLM 在 discover 后无法看到 MCP 等 deferred 工具的参数定义。

## 根因
Output 类型中缺少承载工具详情的字段,buildSearchResult() 和
mapToolResultToToolResultBlockParam() 均未处理 discover 模式的详情渲染。

## 修复方案
- Output 类型新增可选 details 字段(name、description、score、inputSchema)
- buildSearchResult() 接受并透传 details
- discover 分支将 TF-IDF 结果结构化传入输出
- mapToolResultToToolResultBlockParam() 新增 discover 模式渲染路径
- 更新 prompt 中 discover 模式描述,提及 input schema 和执行链

## 变更要点
- SearchExtraToolsTool.ts:4 处改动(Output schema、buildSearchResult、
  discover 分支、渲染逻辑)
- SearchExtraToolsTool.test.ts:更新 1 个测试 + 新增 1 个渲染测试
- prompt.ts:更新 discover 描述

## 自测
- 7 tests / 0 fail(新增 1 个渲染测试,更新 1 个 discover 测试)
- tsc --noEmit 无新增类型错误
- JSONL 日志验证 LLM 可正确解析返回的 schema

Co-Authored-By: CoStrict-DeepSeek-V4-Pro <deepseek-ai@claude-code-best.win>
This commit is contained in:
IronRookieCoder 2026-05-21 16:14:00 +08:00
parent eded375fb4
commit 14f334b93d
3 changed files with 76 additions and 15 deletions

View File

@ -58,6 +58,17 @@ export const outputSchema = lazySchema(() =>
pending_mcp_servers: z.array(z.string()).optional(),
/** Matches that are already loaded (core tools) and can be called directly. */
already_loaded: z.array(z.string()).optional(),
/** Tool details (name, description, schema) for discover mode. */
details: z
.array(
z.object({
name: z.string(),
description: z.string(),
score: z.number(),
inputSchema: z.record(z.string(), z.unknown()).optional(),
}),
)
.optional(),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>
@ -131,6 +142,7 @@ function buildSearchResult(
totalDeferredTools: number,
pendingMcpServers?: string[],
alreadyLoaded?: string[],
details?: Output['details'],
): { data: Output } {
return {
data: {
@ -143,6 +155,7 @@ function buildSearchResult(
...(alreadyLoaded && alreadyLoaded.length > 0
? { already_loaded: alreadyLoaded }
: {}),
...(details && details.length > 0 ? { details } : {}),
},
}
}
@ -446,17 +459,12 @@ export const SearchExtraToolsTool = buildTool({
const discoverQuery = discoverMatch[1]!.trim()
const index = await getToolIndex(deferredTools)
const tfIdfResults = searchTools(discoverQuery, index, max_results)
const textResults = tfIdfResults.map(r => {
let line = `**${r.name}** (score: ${r.score.toFixed(2)})\n${r.description}`
if (r.inputSchema) {
line += `\nSchema: ${JSON.stringify(r.inputSchema)}`
}
return line
})
const text =
textResults.length > 0
? `Found ${textResults.length} tools:\n${textResults.join('\n\n')}`
: 'No matching deferred tools found'
const details = tfIdfResults.map(r => ({
name: r.name,
description: r.description,
score: r.score,
...(r.inputSchema ? { inputSchema: r.inputSchema as Record<string, unknown> } : {}),
}))
logSearchOutcome(
tfIdfResults.map(r => r.name),
'keyword',
@ -465,6 +473,9 @@ export const SearchExtraToolsTool = buildTool({
tfIdfResults.map(r => r.name),
query,
deferredTools.length,
undefined,
undefined,
details,
)
}
@ -559,6 +570,26 @@ export const SearchExtraToolsTool = buildTool({
}
}
// Discover mode: render tool details with name, description, and input schema.
// This lets the model inspect parameters before selecting and invoking a tool.
if (content.details && content.details.length > 0) {
const lines = content.details.map(d => {
let line = `**${d.name}** (score: ${d.score.toFixed(2)})\n${d.description}`
if (d.inputSchema) {
line += `\nSchema: ${JSON.stringify(d.inputSchema)}`
}
return line
})
const text =
`Found ${lines.length} tool(s) matching your query:\n\n` +
lines.join('\n\n')
return {
type: 'tool_result',
tool_use_id: toolUseID,
content: text,
}
}
// Separate already-loaded (core) tools from truly deferred tools
const alreadyLoadedNames = content.already_loaded ?? []
const deferredNames = content.matches.filter(

View File

@ -101,7 +101,7 @@ function makeContext(tools: unknown[] = []) {
}
describe('SearchExtraToolsTool search enhancements', () => {
test('discover: prefix triggers TF-IDF search and returns matches', async () => {
test('discover: prefix triggers TF-IDF search and returns matches with details', async () => {
const mockTool = makeDeferredTool('CronCreate', 'Schedule cron jobs')
mockGetToolIndex.mockResolvedValueOnce([])
mockSearchTools.mockReturnValueOnce([
@ -112,11 +112,11 @@ describe('SearchExtraToolsTool search enhancements', () => {
score: 0.85,
isMcp: false,
isDeferred: true,
inputSchema: undefined,
inputSchema: { interval: { type: 'string' } },
},
])
const result: { data: { matches: string[] } } = await (
const result: { data: { matches: string[]; details?: Array<{ name: string; description: string; score: number; inputSchema?: Record<string, unknown> }> } } = await (
SearchExtraToolsTool as any
).call(
{ query: 'discover:schedule cron job', max_results: 5 },
@ -127,6 +127,11 @@ describe('SearchExtraToolsTool search enhancements', () => {
)
expect(result.data.matches).toContain('CronCreate')
expect(result.data.details).toBeDefined()
expect(result.data.details!.length).toBe(1)
expect(result.data.details![0]!.name).toBe('CronCreate')
expect(result.data.details![0]!.description).toBe('Schedule cron jobs')
expect(result.data.details![0]!.inputSchema).toEqual({ interval: { type: 'string' } })
})
test('keyword + TF-IDF parallel search merges results', async () => {
@ -232,4 +237,29 @@ describe('SearchExtraToolsTool search enhancements', () => {
expect(blockParam.content).toContain('No matching deferred tools found')
})
test('discover mode renders details with description and schema', async () => {
const blockParam = SearchExtraToolsTool.mapToolResultToToolResultBlockParam(
{
matches: ['CronCreate'],
query: 'discover:schedule',
total_deferred_tools: 5,
details: [
{
name: 'CronCreate',
description: 'Schedule cron jobs',
score: 0.85,
inputSchema: { interval: { type: 'string' } },
},
],
},
'tool-use-123',
)
expect(typeof blockParam.content).toBe('string')
expect(blockParam.content as string).toContain('CronCreate')
expect(blockParam.content as string).toContain('Schedule cron jobs')
expect(blockParam.content as string).toContain('Schema')
expect(blockParam.content as string).toContain('"interval"')
})
})

View File

@ -29,7 +29,7 @@ IMPORTANT: ExecuteExtraTool is always available in your tool list. After this se
Query forms:
- "select:CronCreate,Snip" fetch these exact tools by name
- "discover:schedule cron job" pure discovery, returns tool info (name, description) without loading. Use when you want to understand available tools before deciding which to invoke.
- "discover:schedule cron job" pure discovery, returns tool info (name, description, input schema) without loading. Use when you need to understand a tool's parameters before invoking it, then call ExecuteExtraTool with the discovered tool name and params.
- "notebook jupyter" keyword search, up to max_results best matches
- "+slack send" require "slack" in the name, rank by remaining terms`