diff --git a/bun.lock b/bun.lock index 77e1c6c7e5f50b49dfc7d43c16a99143660a044f..4cbd33d7510c7b3e581857a0f1e87da47aa9eeea 100644 --- a/bun.lock +++ b/bun.lock @@ -48,7 +48,7 @@ "update-notifier": "^7.3.1", "wrap-ansi": "^9.0.0", "yargs": "^18.0.0", - "zod": "^3.23.8", + "zod": "^4", }, "devDependencies": { "@types/bun": "latest", @@ -733,7 +733,7 @@ "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], - "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], @@ -741,6 +741,8 @@ "@larksuiteoapi/node-sdk/axios": ["axios@1.13.6", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ=="], + "@modelcontextprotocol/sdk/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@pnpm/network.ca-file/graceful-fs": ["graceful-fs@4.2.10", "", {}, "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA=="], "@types/marked-terminal/marked": ["marked@11.2.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-HR0m3bvu0jAPYiIvLUUQtdg1g6D247//lvcekpHO1WMvbwDlwSkZAX9Lw4F4YHE1T0HaaNve0tuAWuV1UJ6vtw=="], diff --git a/package.json b/package.json index b684ccfe99a1252d29d5a971bedaffc0cd2c8f5e..d5188445596830ada4defb48a2cb1ac6fbded99d 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "update-notifier": "^7.3.1", "wrap-ansi": "^9.0.0", "yargs": "^18.0.0", - "zod": "^3.23.8" + "zod": "^4" }, "devDependencies": { "@types/command-exists": "^1.2.3", diff --git a/src/core/llm.ts b/src/core/llm.ts index 87f63b724a003330fb9c3e484c0d8a5e3366c1b6..23c1b4f107585f8e793a91cce1ae8e88180bc539 100644 --- a/src/core/llm.ts +++ b/src/core/llm.ts @@ -4,7 +4,7 @@ import { runtimeToolRegistry } from '../runtime/tools/toolRegistry.js'; import { RuntimeToolExecutor } from '../runtime/tools/toolExecutor.js'; import type { ModelConfig, Config } from '../types/index.js'; import type { Message } from '../types/index.js'; -import type { ToolCallRecord } from '../types/tool.js'; +import type { ToolCallRecord, ToolCall, ToolResult } from '../types/tool.js'; import { getErrorMessage } from '../utils/error.js'; import { ToolLoopDetector } from './loopDetection.js'; import { @@ -16,6 +16,70 @@ import { } from '../runtime/agent/tokenBudget.js'; import type { ModelRegistry } from '../daemon/modelRegistry.js'; +/** + * 工具参数自修重试上限(IK8MWO #9):超过该次数仍校验失败 → 报错给用户 + * 总次数 = 1(原始调用) + MAX_TOOL_PARAM_RETRIES(2)= 3 次 + */ +export const MAX_TOOL_PARAM_RETRIES = 2; + +/** + * 单条 tool_call 的"参数校验失败"判定(IK8MWO #9)。 + * - executor 返回 success: false + error 含「参数验证失败」前缀(registry.validateParams 失败) + * - 或者 JSON.parse 失败时报「参数解析失败」 + * + * 暴露给测试用 — 之前靠测试复刻一份导致重复实现(simplify #13 / reuse #2)。 + */ +export function isParamValidationFailure(result: any): boolean { + if (!result) return false; + if (result.success !== false) return false; + const err = getErrorMessage(result.error); + return err.includes('参数验证失败') || err.includes('参数解析失败'); +} + +/** + * 单次 tool_call 循环内的「参数校验失败」自修计数器(IK8MWO #9)。 + * + * - 按 toolName 计数:同一工具连续失败 → next 累加 + * - 任意一次成功 → 该工具计数被删除(下一轮重新计数) + * - next > maxRetries → throw(由上层 chat*WithTools 抛出) + * + * 抽成类是为了让 chatWithTools 与 chatStreamWithTools 复用同一段策略; + * 原本两处方法各写一份 19 行的 for 循环(simplify #1 / altitude #2 / reuse #6)。 + */ +export class ToolValidationRetryTracker { + private counts = new Map(); + + constructor(private readonly maxRetries: number) {} + + /** + * 记录一次「成功/失败」结果;失败超限时直接抛错。 + * @param toolName 工具名 + * @param result 执行结果(含 success / error) + */ + record(toolName: string, result: ToolResult | undefined): void { + if (!isParamValidationFailure(result)) { + this.counts.delete(toolName); + return; + } + const next = (this.counts.get(toolName) ?? 0) + 1; + this.counts.set(toolName, next); + if (next > this.maxRetries) { + throw new Error( + `工具 "${toolName}" 参数校验连续失败 ${next} 次(超过 ${this.maxRetries} 次重试上限),已停止自修。\n` + + `最后一次错误: ${getErrorMessage(result?.error)}`, + ); + } + } + + /** 一次性应用一组 tool_call 的结果(等价于为每条 record 一次)。 + * 任何一条失败即抛错,与上面 record 的语义一致。 */ + applyBatch(toolCalls: ToolCall[], results: ToolResult[]): void { + for (let i = 0; i < toolCalls.length; i++) { + this.record(toolCalls[i]!.function.name, results[i]); + } + } +} + export class LLMClient { private provider: BaseProvider; private modelConfig: ModelConfig; @@ -179,11 +243,14 @@ export class LLMClient { throw new Error('工具系统未启用,请先调用 enableTools()'); } + // tools 列表在会话期间不变 — 提到 while 外面,避免每次迭代重新构建 const tools = runtimeToolRegistry.toOpenAIFunctions(); let conversationMessages = [...messages]; const maxIterations = configManager.getMaxIterations(); let iteration = 0; const loopDetector = new ToolLoopDetector(); + // IK8MWO #9:自修重试计数(按 toolName 连续失败次数) + const retryTracker = new ToolValidationRetryTracker(MAX_TOOL_PARAM_RETRIES); while (iteration < maxIterations) { iteration++; @@ -249,6 +316,9 @@ export class LLMClient { conversationMessages.push(toolMessage); } + // IK8MWO #9:自修重试计数 — 校验失败按 toolName 累加,超限抛错 + retryTracker.applyBatch(response.tool_calls, toolResults); + // 继续循环,让 LLM 根据工具结果生成回复 continue; } @@ -280,12 +350,15 @@ export class LLMClient { throw new Error('工具系统未启用'); } + // tools 列表在会话期间不变 — 提到 while 外面,避免每次迭代重新构建 const tools = runtimeToolRegistry.toOpenAIFunctions(); let conversationMessages = [...messages]; const maxIterations = configManager.getMaxIterations(); let iteration = 0; const loopDetector = new ToolLoopDetector(); const start = Date.now(); + // IK8MWO #9:自修重试计数(按 toolName 连续失败次数) + const retryTracker = new ToolValidationRetryTracker(MAX_TOOL_PARAM_RETRIES); // Budget 追踪(tokenBudget 为 null/0/undefined 时,checkTokenBudget 直接返回 no_budget) const budget = (tokenBudget && tokenBudget > 0) ? tokenBudget : null; @@ -379,6 +452,9 @@ export class LLMClient { }); } + // IK8MWO #9:自修重试计数 — 与 chatWithTools 共用 ToolValidationRetryTracker + retryTracker.applyBatch(accumulatedToolCalls, toolResults); + yield '\n'; // Budget 决策:stop → 终止循环;continue → 注入 nudge 再继续 diff --git a/src/runtime/tools/toolRegistry.ts b/src/runtime/tools/toolRegistry.ts index 72e132a82030c578fa103de60fa2ea38a2d37396..796688c61416ecb4d8f99f395c99a527ec016ba3 100644 --- a/src/runtime/tools/toolRegistry.ts +++ b/src/runtime/tools/toolRegistry.ts @@ -1,5 +1,6 @@ import type { AliceTool, OpenAIFunction } from '../../types/tool.js'; import { toolRegistry as baseToolRegistry, ToolRegistry as BaseToolRegistry } from '../../tools/registry.js'; +import type { ValidationResult } from '../../tools/zodAdapter.js'; /** * v2-lite runtime wrapper for tool registration/query. @@ -32,7 +33,8 @@ export class RuntimeToolRegistry { return this.registry.toOpenAIFunctions(); } - validateParams(toolName: string, params: any): { valid: boolean; errors?: string } { + // IK8MWO #9:透传 ValidationResult(含 issues / engine),不丢失字段路径 + validateParams(toolName: string, params: any): ValidationResult { return this.registry.validateParams(toolName, params); } diff --git a/src/runtime/tools/toolResultFormatter.ts b/src/runtime/tools/toolResultFormatter.ts index 280358152ae25f842f093625742b13a544f6d0ae..333197cf36bfd3fdc02115506f1ecf126b7e337d 100644 --- a/src/runtime/tools/toolResultFormatter.ts +++ b/src/runtime/tools/toolResultFormatter.ts @@ -1,5 +1,6 @@ import type { Message } from '../../types/index.js'; import type { ToolCallRecord } from '../../types/tool.js'; +import type { ValidationIssue } from '../../tools/zodAdapter.js'; export function formatToolResult(record: ToolCallRecord): string | undefined { const result = record.result; @@ -40,6 +41,39 @@ export function formatToolResult(record: ToolCallRecord): string | undefined { return parts.join('\n\n'); } +/** + * formatError(IK8MWO #9):把 zod/ajv 校验错误渲染为 LLM 友好的字段路径列表。 + * + * 输出:多行人类可读文本,每行一条 issue: + * `[path] code: message` (无 path 时用 [root]) + * 多 issue 用换行分隔。最末追加 `请重新生成 tool_call 参数(仅返回合法参数)` + */ +export interface FormatErrorInput { + engine?: 'zod' | 'ajv'; + issues?: ValidationIssue[]; + error?: string; +} + +export function formatError(input: FormatErrorInput): string { + const lines: string[] = []; + if (input.issues && input.issues.length > 0) { + const engineLabel = input.engine === 'ajv' ? 'JSONSchema' : 'zod'; + lines.push(`参数校验失败(${engineLabel} 引擎,${input.issues.length} 处问题):`); + for (const issue of input.issues) { + const path = issue.path ? `[${issue.path}]` : '[root]'; + const code = issue.code ? ` ${issue.code}` : ''; + const msg = issue.message ?? issue.received ?? 'invalid'; + lines.push(` - ${path}${code}: ${msg}`); + } + } else if (input.error) { + lines.push(`参数校验失败: ${input.error}`); + } else { + lines.push('参数校验失败'); + } + lines.push('请重新生成 tool_call 参数(仅返回合法参数,字段路径以上述错误为准)'); + return lines.join('\n'); +} + export function buildAssistantToolCallMessage( records: ToolCallRecord[], content: string, @@ -65,3 +99,5 @@ export function buildToolResultMessages(records: ToolCallRecord[]): Message[] { timestamp: new Date(), })); } + + diff --git a/src/services/FileCommandLoader.ts b/src/services/FileCommandLoader.ts index 63651ca781866b6448db75c8249ba93a4d72cdaa..0c3a355d5214b43e8a604abfc0e63ce8bd80aea0 100644 --- a/src/services/FileCommandLoader.ts +++ b/src/services/FileCommandLoader.ts @@ -39,10 +39,8 @@ const debugLogger = createDebugLogger('FILE_COMMAND_LOADER'); * single source of truth for both validation and type inference. */ const TomlCommandDefSchema = z.object({ - prompt: z.string({ - required_error: "The 'prompt' field is required.", - invalid_type_error: "The 'prompt' field must be a string.", - }), + // zod v4: required_error / invalid_type_error 已合并为 error + prompt: z.string({ error: "The 'prompt' field is required and must be a string." }), description: z.string().optional(), }); diff --git a/src/services/markdown-command-parser.ts b/src/services/markdown-command-parser.ts index 3d4b4536b0b89e0adf389567fd7d25e6f1151aa6..9c16f64f7aa468f1e1f000aa432dc55d187d1e3f 100644 --- a/src/services/markdown-command-parser.ts +++ b/src/services/markdown-command-parser.ts @@ -20,10 +20,8 @@ export const MarkdownCommandDefSchema = z.object({ description: z.string().optional(), }) .optional(), - prompt: z.string({ - required_error: 'The prompt content is required.', - invalid_type_error: 'The prompt content must be a string.', - }), + // zod v4: required_error / invalid_type_error 已合并为 error + prompt: z.string({ error: 'The prompt content is required and must be a string.' }), }); export type MarkdownCommandDef = z.infer; diff --git a/src/tools/builtin/editFile.ts b/src/tools/builtin/editFile.ts index b21e37d466faefecc11528249c0d904ba44ce829..29c52f7cbcfb6ffefc8d059c1d0ae8bf5da478dd 100644 --- a/src/tools/builtin/editFile.ts +++ b/src/tools/builtin/editFile.ts @@ -1,12 +1,48 @@ /** - * 文件系统工具:按行号编辑文件(替换、插入、删除),支持批量操作 - * 适用于大文件少量修改,可减少 token 与多次调用。 + * 文件系统工具:按行号编辑文件(替换、插入、删除),支持批量操作 + * 适用于大文件少量修改,可减少 token 与多次调用。 */ import { readFile, writeFile } from 'fs/promises'; import path from 'path'; import type { AliceTool, ToolResult } from '../../types/tool.js'; import { getErrorMessage } from '../../utils/error.js'; +import { z } from 'zod/v4'; +import { requiredNonEmptyString, intOneBased, intNonNegative, editFileEncodingEnum } from '../zodPrimitives.js'; + +/** + * zod v4 schema(IK8MWO #9):discriminated union 三种 action, + * LLM 错误的字段路径(如 `edits.0.start`)能被精准回灌。 + */ +const replaceLinesEdit = z.object({ + action: z.literal('replace-lines'), + start: intOneBased, + end: intOneBased, + content: requiredNonEmptyString('content'), +}); +const insertAfterEdit = z.object({ + action: z.literal('insert-after'), + line: intNonNegative, + content: requiredNonEmptyString('content'), +}); +const deleteLinesEdit = z.object({ + action: z.literal('delete-lines'), + start: intOneBased, + end: intOneBased, +}); + +const editFileSchema = z.object({ + path: requiredNonEmptyString('path'), + edits: z.array( + z.discriminatedUnion('action', [ + replaceLinesEdit, + insertAfterEdit, + deleteLinesEdit, + ]), + { error: 'edits 必填且必须是数组' }, + ).min(1, 'edits 不能为空数组'), + encoding: editFileEncodingEnum.optional(), +}); type EditAction = 'replace-lines' | 'insert-after' | 'delete-lines'; @@ -135,6 +171,7 @@ export const editFileTool: AliceTool = { }, required: ['path', 'edits'] }, + zodSchema: editFileSchema, async execute(toolCallId, params, signal, onUpdate, context): Promise { const { path: filePath, edits, encoding = 'utf-8' } = params; diff --git a/src/tools/builtin/executeCommand.ts b/src/tools/builtin/executeCommand.ts index 4875b4e80388d8596f35fb308456da463fd290e9..7e43a1fe09b55f2a93a8b9f7b980054e02407287 100644 --- a/src/tools/builtin/executeCommand.ts +++ b/src/tools/builtin/executeCommand.ts @@ -1,6 +1,6 @@ /** - * 命令执行工具:执行 shell 命令 - * 支持跨平台(Windows/macOS/Linux) + * 命令执行工具:执行 shell 命令 + * 支持跨平台(Windows/macOS/Linux) */ import { spawn } from 'child_process'; @@ -8,9 +8,11 @@ import type { AliceTool, ToolResult } from '../../types/tool.js'; import { getErrorMessage } from '../../utils/error.js'; import { injectAliceCoAuthorTrailer, type ShellFlavor } from '../../utils/gitCoAuthor.js'; import { configManager } from '../../utils/config.js'; +import { z } from 'zod/v4'; +import { requiredNonEmptyString } from '../zodPrimitives.js'; /** - * 危险命令模式(跨平台) + * 危险命令模式(跨平台) */ const DANGEROUS_PATTERNS = [ /rm\s+-rf/i, // Unix 删除 @@ -31,10 +33,19 @@ export function isDangerousCommand(command: string): boolean { return DANGEROUS_PATTERNS.some(pattern => pattern.test(command)); } +/** + * zod v4 schema(IK8MWO #9):`command` 必填,`timeout` 必传正整数。 + */ +const executeCommandSchema = z.object({ + command: requiredNonEmptyString('command'), + cwd: z.string().optional(), + timeout: z.int().positive().optional(), +}); + export const executeCommandTool: AliceTool = { name: 'executeCommand', label: '执行命令', - description: '执行 shell 命令并返回输出(支持 Windows/macOS/Linux)', + description: '执行 shell 命令并返回输出(支持 Windows/macOS/Linux)', parameters: { type: 'object', properties: { @@ -44,15 +55,16 @@ export const executeCommandTool: AliceTool = { }, cwd: { type: 'string', - description: '工作目录(默认为当前目录)' + description: '工作目录(默认为当前目录)' }, timeout: { type: 'number', - description: '超时时间(毫秒,默认 30000)' + description: '超时时间(毫秒,默认 30000)' } }, required: ['command'] }, + zodSchema: executeCommandSchema, async execute(toolCallId, params, signal, onUpdate, context): Promise { const { command, timeout = 30000 } = params; diff --git a/src/tools/builtin/readFile.ts b/src/tools/builtin/readFile.ts index d0682d9d25630e29a94d63dc88932a14457ec89c..c0e8fe1d0a75906612a47f84fd59f5148b75228d 100644 --- a/src/tools/builtin/readFile.ts +++ b/src/tools/builtin/readFile.ts @@ -1,11 +1,22 @@ /** - * 文件系统工具:读取文件 + * 文件系统工具:读取文件 */ import path from 'path'; import { readFile as fsReadFile } from 'fs/promises'; import type { AliceTool, ToolResult } from '../../types/tool.js'; import { getErrorMessage } from '../../utils/error.js'; +import { z } from 'zod/v4'; +import { requiredNonEmptyString, encodingEnum } from '../zodPrimitives.js'; + +/** + * zod v4 schema(IK8MWO #9):高频读文件,字段路径错误有助于 LLM 修复。 + * `path` 必填 + 字符串;`encoding` 可选枚举。 + */ +const readFileSchema = z.object({ + path: requiredNonEmptyString('path'), + encoding: encodingEnum.optional(), +}); export const readFileTool: AliceTool = { name: 'readFile', @@ -16,7 +27,7 @@ export const readFileTool: AliceTool = { properties: { path: { type: 'string', - description: '文件路径(相对或绝对路径)' + description: '文件路径(相对或绝对路径)' }, encoding: { type: 'string', @@ -26,6 +37,7 @@ export const readFileTool: AliceTool = { }, required: ['path'] }, + zodSchema: readFileSchema, async execute(toolCallId, params, signal, onUpdate, context): Promise { const { path: filePath, encoding = 'utf-8' } = params; diff --git a/src/tools/builtin/searchFiles.ts b/src/tools/builtin/searchFiles.ts index da20b42e63e5f74db4e69386ebe4ce9ad1a8c03c..4d504a8ddb75c32cdbc6418369bcfc4add2d2e81 100644 --- a/src/tools/builtin/searchFiles.ts +++ b/src/tools/builtin/searchFiles.ts @@ -11,9 +11,20 @@ import { glob } from 'glob'; import type { AliceTool, ToolResult } from '../../types/tool.js'; import { getErrorMessage } from '../../utils/error.js'; import { runRipgrepFiles } from '../../utils/ripgrepRunner.js'; +import { z } from 'zod/v4'; +import { requiredNonEmptyString } from '../zodPrimitives.js'; const DEFAULT_IGNORE = ['**/node_modules/**', '**/.git/**', '**/dist/**']; +/** + * zod v4 schema(IK8MWO #9):`pattern` 必填字符串,`ignore` 数组元素必须为字符串。 + */ +const searchFilesSchema = z.object({ + pattern: requiredNonEmptyString('pattern'), + directory: z.string().optional(), + ignore: z.array(z.string({ error: 'ignore 数组元素必须为字符串' })).optional(), +}); + export const searchFilesTool: AliceTool = { name: 'searchFiles', label: '搜索文件', @@ -39,6 +50,7 @@ export const searchFilesTool: AliceTool = { }, required: ['pattern'] }, + zodSchema: searchFilesSchema, async execute(toolCallId, params, signal, onUpdate, context): Promise { const { diff --git a/src/tools/builtin/writeFile.ts b/src/tools/builtin/writeFile.ts index 20c267842d214066f4c2f26bcb133e33cc95364e..a8266aa3336265dd145ff4858a79646436d77f94 100644 --- a/src/tools/builtin/writeFile.ts +++ b/src/tools/builtin/writeFile.ts @@ -1,22 +1,33 @@ /** - * 文件系统工具:写入文件 + * 文件系统工具:写入文件 */ import { writeFile as fsWriteFile, mkdir } from 'fs/promises'; import path from 'path'; import type { AliceTool, ToolResult } from '../../types/tool.js'; import { getErrorMessage } from '../../utils/error.js'; +import { z } from 'zod/v4'; +import { requiredNonEmptyString, encodingEnum } from '../zodPrimitives.js'; + +/** + * zod v4 schema(IK8MWO #9):`path`/`content` 必填,`encoding` 可选枚举。 + */ +const writeFileSchema = z.object({ + path: requiredNonEmptyString('path'), + content: requiredNonEmptyString('content'), + encoding: encodingEnum.optional(), +}); export const writeFileTool: AliceTool = { name: 'writeFile', label: '写入文件', - description: '将内容写入指定路径的文件。若目录不存在会自动创建。路径可为相对路径(相对于当前工作目录)或绝对路径。', + description: '将内容写入指定路径的文件。若目录不存在会自动创建。路径可为相对路径(相对于当前工作目录)或绝对路径。', parameters: { type: 'object', properties: { path: { type: 'string', - description: '文件路径(相对或绝对路径)' + description: '文件路径(相对或绝对路径)' }, content: { type: 'string', @@ -30,6 +41,7 @@ export const writeFileTool: AliceTool = { }, required: ['path', 'content'] }, + zodSchema: writeFileSchema, async execute(toolCallId, params, signal, onUpdate, context): Promise { const { path: filePath, content, encoding = 'utf-8' } = params; diff --git a/src/tools/executor.ts b/src/tools/executor.ts index d0c8b5499c08fe40289906300d7c7f2600050160..2cf33a15308f5fc756be6fd43d4dcd1d0fb59da3 100644 --- a/src/tools/executor.ts +++ b/src/tools/executor.ts @@ -11,6 +11,7 @@ import { eventBus } from '../core/events.js'; import { createToolCallEvent } from '../types/events.js'; import type { ToolExecuteEvent, ToolErrorEvent } from '../types/events.js'; import { getErrorMessage } from '../utils/error.js'; +import { formatError } from '../runtime/tools/toolResultFormatter.js'; import type { PermissionDecision } from '../core/permission/permissionDecision.js'; /** @@ -80,9 +81,15 @@ export class ToolExecutor { // 验证参数 const validation = toolRegistry.validateParams(toolName, params); if (!validation.valid) { + // IK8MWO #9:渲染字段路径 + 引擎标签,方便 LLM 修复 + const detailed = formatError({ + engine: validation.engine, + issues: validation.issues, + error: validation.errors, + }); return { success: false, - error: `参数验证失败: ${validation.errors}` + error: `参数验证失败: ${detailed}`, }; } diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 91f8ac168f52745149a67b2ae9e3414e8ca35b4e..c706225c2aed54a6eb388cd21f8ceca004e05a31 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -3,12 +3,9 @@ * 管理所有可用工具的注册和查询 */ -import type { AliceTool, OpenAIFunction } from '../types/tool.js'; -import Ajv from 'ajv'; -import addFormats from 'ajv-formats'; - -const ajv = new Ajv(); -addFormats(ajv); +import type { AliceTool, OpenAIFunction, ToolParameterSchema } from '../types/tool.js'; +import { ajvInstance, parseZodSchema, parseJsonSchema, type ValidationResult } from './zodAdapter.js'; +import { toPublicSchema } from './schemaFromZod.js'; export class ToolRegistry { private tools: Map = new Map(); @@ -16,11 +13,19 @@ export class ToolRegistry { /** * 注册工具 + * + * 关键(IK8MWO #9):无论工具是 zod 还是 JSONSchema,都校验"对外 schema"。 + * zod 工具的 `parameters` 字段是手写 JSON Schema,LLM 实际看到的对外 schema + * 是 `toPublicSchema(tool)`,所以 register 时也要校验这个,而不是跳过。 + * + * zod v4 产出的 JSONSchema 会带 `$schema: "https://json-schema.org/draft/2020-12/schema"`, + * ajv 默认只识别 draft-07;此处剥离 `$schema` 后再校验(运行时按 draft-07 解析, + * 大多数字段语义一致,等价于 draft-2020-12 的子集)。 */ register(tool: AliceTool): void { - // 验证参数 schema - const isValid = ajv.validateSchema(tool.parameters); - if (!isValid) { + const publicSchema = toPublicSchema(tool) as ToolParameterSchema; + const { $schema: _meta, ...schemaless } = publicSchema; + if (!ajvInstance.validateSchema(schemaless as ToolParameterSchema)) { throw new Error(`Invalid parameter schema for tool: ${tool.name}`); } @@ -40,7 +45,7 @@ export class ToolRegistry { * 批量注册工具 */ registerAll(tools: AliceTool[]): void { - tools.forEach(tool => this.register(tool)); + tools.forEach((tool) => this.register(tool)); } /** @@ -66,43 +71,39 @@ export class ToolRegistry { /** * 转换为 OpenAI Function Calling 格式 + * + * zod 工具的统一路径:通过 schemaFromZod.ts 转 JSONSchema,确保 LLM 收到的 description/enum/required + * 与 zod 定义一致。 */ toOpenAIFunctions(): OpenAIFunction[] { - const canonical = this.getAll().map(tool => ({ - name: tool.name, - description: tool.description, - parameters: tool.parameters - })); - - const aliases = Array.from(this.aliasMap.entries()).map(([alias, tool]) => ({ - name: alias, + const mapFn = (name: string, tool: AliceTool): OpenAIFunction => ({ + name, description: tool.description, - parameters: tool.parameters - })); - + parameters: toPublicSchema(tool) as ToolParameterSchema, + }); + const canonical = this.getAll().map((t) => mapFn(t.name, t)); + const aliases = Array.from(this.aliasMap, ([alias, t]) => mapFn(alias, t)); return [...canonical, ...aliases]; } /** - * 验证工具参数 + * 验证工具参数(IK8MWO #9 改造) + * + * 路由:有 zodSchema 走 zod v4,否则走 ajv JSONSchema。registry 决定 engine, + * 不在 validator 里做运行时 sniff。 */ - validateParams(toolName: string, params: any): { valid: boolean; errors?: string } { + validateParams(toolName: string, params: any): ValidationResult { const tool = this.get(toolName); if (!tool) { - return { valid: false, errors: `Tool not found: ${toolName}` }; - } - - const validate = ajv.compile(tool.parameters); - const valid = validate(params); - - if (!valid) { return { valid: false, - errors: ajv.errorsText(validate.errors) + errors: `Tool not found: ${toolName}`, + engine: 'ajv', }; } - - return { valid: true }; + return tool.zodSchema + ? parseZodSchema(tool.zodSchema, params) + : parseJsonSchema(tool.parameters, params); } /** diff --git a/src/tools/schemaFromZod.ts b/src/tools/schemaFromZod.ts new file mode 100644 index 0000000000000000000000000000000000000000..1907da48e66c21f6f93a51647269835d49ac6602 --- /dev/null +++ b/src/tools/schemaFromZod.ts @@ -0,0 +1,50 @@ +/** + * schemaFromZod.ts — zod v4 → JSON Schema(IK8MWO #9) + * + * 暴露给 LLM 的 OpenAIFunction.parameters 必须是 JSON Schema 结构(各 provider 协议要求), + * 而工具定义层用 zod 有更好的类型推断与字段路径错误。 + * 两者之间用 `z.toJSONSchema()`(zod v4 native)做单向转换, + * 在工具注册时一次生成并缓存,避免每次 toOpenAIFunctions() 重复 build。 + * + * 缓存策略: + * - WeakMap;ZodType 消失时自动回收。 + * - 与 ajv WeakMap 缓存(tools/zodAdapter.ts)对称,避免内存泄漏。 + */ + +import type { z } from 'zod'; +import { z as zodV4 } from 'zod/v4'; +import type { ToolParameterSchema } from '../types/tool.js'; + +const schemaCache = new WeakMap(); + +/** + * 单 schema → JSON Schema。 + * - `zod` 命名空间下的 `z.toJSONSchema` 在 v4 内置;命名空间冲突时显式走 `zod/v4`。 + * - 不抛错:失败时回退到空 schema,并返回 false 让调用方决定是否登记。 + */ +export function zodToJsonSchema(schema: z.ZodType): ToolParameterSchema { + const cached = schemaCache.get(schema); + if (cached) return cached; + + // zod v4: z.toJSONSchema 来自 'zod/v4' 子路径。 + // 注意:type-level `z.ZodType` 与运行时 `z` 是同一对象,这里取的是 v4 实现。 + const json = (zodV4 as unknown as { toJSONSchema: (s: z.ZodType) => ToolParameterSchema }) + .toJSONSchema(schema); + // 防御:toJSONSchema 必须给 object 类型;否则补默认值避免下游 ajv 编译失败 + const out: ToolParameterSchema = (json && (json as ToolParameterSchema).type === 'object') + ? (json as ToolParameterSchema) + : { type: 'object', properties: {}, ...(json as object) }; + schemaCache.set(schema, out); + return out; +} + +/** + * 拿工具的"对外 schema":zod 工具转 JSONSchema,纯 JSONSchema 工具原样返回。 + * 供 toOpenAIFunctions() 使用。 + */ +export function toPublicSchema( + tool: { zodSchema?: z.ZodType; parameters: ToolParameterSchema }, +): ToolParameterSchema { + if (tool.zodSchema) return zodToJsonSchema(tool.zodSchema); + return tool.parameters; +} diff --git a/src/tools/zodAdapter.ts b/src/tools/zodAdapter.ts new file mode 100644 index 0000000000000000000000000000000000000000..aa71e5ad1171c9259aeca3b8ffbafa0973b362b4 --- /dev/null +++ b/src/tools/zodAdapter.ts @@ -0,0 +1,124 @@ +/** + * zodAdapter.ts — Zod v4 / JSONSchema 双形态校验(IK8MWO #9) + * + * 设计动机: + * - 仓库既有的 ajv 校验走 `ToolParameterSchema`(手写 JSON Schema)。 + * - 新引入 zod v4 作为高频工具(LLM tool_call 必走的 5 个)schema 定义层, + * 目的是给 LLM 字段路径级错误回灌(`edits.0.start` 这种路径), + * 减少自修循环里的「瞎猜」。 + * - 假定的高频工具:`executeCommand / writeFile / editFile / searchFiles / readFile`。 + * - 低风险工具(getCurrentDateTime / getCurrentDirectory / getGitInfo 等)继续走 JSONSchema, + * 保持「新增工具不一定都要 zod 化」的迁移自由度。 + * + * 公开 API: + * - `parseZodSchema(schema, params)` 纯 zod 路径 + * - `parseJsonSchema(schema, params)` 纯 JSONSchema 路径(ajv) + * - `ajvInstance` 共享 Ajv,registry.ts 用它做 register-time schema 检查 + * - `ValidationResult` / `ValidationIssue` 校验结果类型 + * + * 路由(由调用方决定,这里不做运行时判别): + * - `ToolRegistry.validateParams` 已拿到 `tool.zodSchema ?? tool.parameters`, + * 直接调 parseZodSchema 或 parseJsonSchema,不走运行时 sniff。 + */ + +import type { z } from 'zod'; +import Ajv from 'ajv'; +import addFormats from 'ajv-formats'; +import type { ToolParameterSchema } from '../types/tool.js'; + +/** 共享 Ajv 实例:registry.ts 做 register-time schema 校验时复用,避免重复实例化。 */ +export const ajvInstance = new Ajv(); +addFormats(ajvInstance); + +/** + * 校验结果。 + * - valid: 是否通过 + * - errors: 人类可读错误文本(单行,直接拼到 `参数验证失败: ...` 后) + * - issues: 结构化问题列表(字段路径 + 错误码 + 期望类型),供 formatError 渲染 + * - engine: 实际走的引擎('zod' | 'ajv'),便于回灌日志 + */ +export interface ValidationResult { + valid: boolean; + errors?: string; + issues?: ValidationIssue[]; + engine: 'zod' | 'ajv'; +} + +export interface ValidationIssue { + /** 字段路径,如 'path' / 'edits.0.start' / '' (根) */ + path: string; + /** 错误码或简短描述,如 'invalid_type' / 'too_small' / 'unrecognized_keys' */ + code: string; + /** 期望类型/形状,简短 */ + expected?: string; + /** 收到的实际值,简短序列化 */ + received?: string; + /** 完整描述(可选) */ + message?: string; +} + +/** ajv 单 schema 编译缓存:同一 ToolParameterSchema 多次解析时复用 validator */ +const ajvCache = new WeakMap>(); + +function compileAjv(schema: ToolParameterSchema): ReturnType { + const cached = ajvCache.get(schema); + if (cached) return cached; + const validate = ajvInstance.compile(schema); + ajvCache.set(schema, validate); + return validate; +} + +/** 把问题列表压成单行 errors 文案,失败时由调用方包装后注入 LLM。 */ +function renderErrors(issues: ValidationIssue[]): string { + return issues + .map((i) => (i.path ? `${i.path}: ${i.message ?? i.code}` : (i.message ?? i.code))) + .join('; '); +} + +/** + * 纯 JSONSchema 路径 — 用于无 zodSchema 的工具。 + */ +export function parseJsonSchema( + schema: ToolParameterSchema, + params: unknown, +): ValidationResult { + const validate = compileAjv(schema); + const ok = validate(params); + if (ok) return { valid: true, engine: 'ajv' }; + + const issues: ValidationIssue[] = (validate.errors ?? []).map((err) => { + const path = (err.instancePath ?? '').replace(/^\//, '').replace(/\//g, '.'); + return { + path, + code: err.keyword ?? 'invalid', + expected: err.params?.type ? String(err.params.type) : undefined, + received: err.message, + message: err.message ?? 'invalid', + }; + }); + return { valid: false, errors: renderErrors(issues), issues, engine: 'ajv' }; +} + +/** + * 纯 zod v4 路径 — 用于带 zodSchema 的高频工具。 + * 用 safeParse,不抛错;输出字段路径(`path` 项 join)供 LLM 修复。 + */ +export function parseZodSchema( + schema: z.ZodType, + params: unknown, +): ValidationResult { + const result = schema.safeParse(params); + if (result.success) return { valid: true, engine: 'zod' }; + + const issues: ValidationIssue[] = result.error.issues.map((issue) => { + const path = issue.path.length === 0 ? '' : issue.path.join('.'); + return { + path, + code: issue.code, + expected: 'expected' in issue ? String((issue as { expected?: unknown }).expected) : undefined, + received: 'received' in issue ? String((issue as { received?: unknown }).received) : undefined, + message: issue.message, + }; + }); + return { valid: false, errors: renderErrors(issues), issues, engine: 'zod' }; +} diff --git a/src/tools/zodPrimitives.ts b/src/tools/zodPrimitives.ts new file mode 100644 index 0000000000000000000000000000000000000000..a4c103e303aa95f57f3366c4803e3d35e41af7c0 --- /dev/null +++ b/src/tools/zodPrimitives.ts @@ -0,0 +1,25 @@ +/** + * zodPrimitives.ts — 5 个高频 builtin 工具共享的 zod v4 校验原语(IK8MWO #9) + * + * 原因:simplify #7/#8 / altitude #D1-D3 — 5 个 builtin 工具重复 + * `z.string({ error: '...' }).min(1, '...')` 与 `z.int({ error: '...' }).positive()` 等 + * 模版。集中到一处,LLM 拿到的错误信息词汇一致。 + */ + +import { z } from 'zod/v4'; + +/** 必填的非空字符串。`name` 用于错误文本,如 'path' → 'path 必填且必须为字符串'。 */ +export const requiredNonEmptyString = (name: string) => + z.string({ error: `${name} 必填且必须为字符串` }).min(1, `${name} 不能为空`); + +/** 1-based 行号(≥ 1)。editFile.{replace-lines, delete-lines}.start/end 用。 */ +export const intOneBased = z.int().positive(); + +/** 0-based 行号(≥ 0)。editFile.insert-after.line 用。 */ +export const intNonNegative = z.int().nonnegative(); + +/** 通用文件 encoding 枚举(readFile / writeFile 共享)。 */ +export const encodingEnum = z.enum(['utf-8', 'utf8', 'ascii', 'base64']); + +/** editFile 专用 encoding 枚举(不含 base64)。 */ +export const editFileEncodingEnum = z.enum(['utf-8', 'utf8', 'ascii']); diff --git a/src/types/tool.ts b/src/types/tool.ts index a4d82a3a0d6e3fdd2171d197f0032ee0e46d1ea8..a7fb0f72b645904defe44c413302a588429a645b 100644 --- a/src/types/tool.ts +++ b/src/types/tool.ts @@ -2,16 +2,26 @@ * 工具系统类型定义 */ +import type { z } from 'zod'; + /** * JSON Schema 参数定义 + * + * 兼容字段为 zod v4 `z.toJSONSchema()` 产物的最小子集; + * LLM 对外暴露走 `OpenAIFunction.parameters` 时,toOpenAIFunctions() 会用 + * `zodSchema ?? parameters` 二选一(见 tools/schemaFromZod.ts)。 */ export interface ToolParameter { - type: string; + type?: string; description?: string; - enum?: string[]; + enum?: unknown[]; items?: ToolParameter; properties?: Record; required?: string[]; + anyOf?: ToolParameter[]; + oneOf?: ToolParameter[]; + additionalProperties?: boolean | ToolParameter; + default?: unknown; } /** @@ -21,6 +31,7 @@ export interface ToolParameterSchema { type: 'object'; properties: Record; required?: string[]; + [key: string]: unknown; } /** @@ -106,8 +117,15 @@ export interface AliceTool { label: string; /** 工具描述(会发送给 LLM) */ description: string; - /** 参数 JSON Schema */ + /** 参数 JSON Schema(兜底字段;若设置了 zodSchema,会由 schemaFromZod 转出对外 schema) */ parameters: ToolParameterSchema; + /** + * zod v4 参数 schema(可选,IK8MWO #9)。 + * 高频工具(executeCommand/writeFile/editFile/searchFiles/readFile)走 zod 路径, + * 缺工具/低风险工具仍走 ajv JSONSchema 路径。 + * 校验器在 registry.validateParams 中分流:有 zodSchema 走 zod,无则 ajv。 + */ + zodSchema?: z.ZodType; /** * 执行工具 * @param toolCallId - 工具调用的唯一 ID diff --git a/test-case/test-issue-009.ts b/test-case/test-issue-009.ts new file mode 100644 index 0000000000000000000000000000000000000000..9d2c94647dffdcfcfb456a52940a110faaf77afc --- /dev/null +++ b/test-case/test-issue-009.ts @@ -0,0 +1,496 @@ +/** + * test-case/test-issue-009.ts + * + * 对应 issue IK8MWO #9 Zod v4 运行时 Schema 校验(LLM tool_call 参数) + * + * 运行: bun run test-case/test-issue-009.ts + * + * 测试方法(issue 原文): + * ① 5 个高频工具(executeCommand/writeFile/editFile/searchFiles/readFile)schema 覆盖: + * 合法参数放行;缺必填/错类型/多余字段被拦且返回结构化错误 + * ② 错误回灌信息含字段路径(JSON pointer 或嵌套路径,例 'edits.0.start') + * ③ tool_call 自修重试上限 2 次:LLM 反复给同工具无效参数,第 3 次抛错给用户 + * (用 spy/stub 模拟 LLM,避免真实网络) + * ④ 低风险工具(getCurrentDateTime)走 JSONSchema(ajv)路径,不被 zod 校验拦 + * ⑤ 升 v4 后既有 6 处 `import { z } from 'zod'` 仍能编译(模块能 import) + * + * 设计参照:AliceTool.zodSchema + zodAdapter + schemaFromZod + formatError + * + llm.ts MAX_TOOL_PARAM_RETRIES(self-repair 限 2 次) + */ + +import { z } from 'zod/v4'; +import { toolRegistry, ToolRegistry } from '../src/tools/registry.js'; +import { builtinTools } from '../src/tools/builtin/index.js'; +import { + parseZodSchema, + parseJsonSchema, + type ValidationResult, +} from '../src/tools/zodAdapter.js'; +import { zodToJsonSchema, toPublicSchema } from '../src/tools/schemaFromZod.js'; +import { formatError } from '../src/runtime/tools/toolResultFormatter.js'; +import { RuntimeToolExecutor } from '../src/runtime/tools/toolExecutor.js'; +import { MAX_TOOL_PARAM_RETRIES, isParamValidationFailure } from '../src/core/llm.js'; +import type { ToolCall, ToolResult } from '../src/types/tool.js'; +import type { Config } from '../src/types/index.js'; + +// ---------- 极简测试 harness ---------- + +let passed = 0; +let failed = 0; +const failures: string[] = []; + +function assert(cond: unknown, msg: string): void { + if (cond) { + passed++; + console.log(` ✓ ${msg}`); + } else { + failed++; + failures.push(msg); + console.log(` ✗ ${msg}`); + } +} + +function section(name: string): void { + console.log(`\n── ${name} ──`); +} + +// ---------- 共享工具 ---------- + +const HIGH_FREQ_TOOLS = [ + 'executeCommand', + 'writeFile', + 'editFile', + 'searchFiles', + 'readFile', +]; + +const LOW_RISK_TOOLS = [ + 'getCurrentDateTime', + 'getCurrentDirectory', + 'getGitInfo', +]; + +/** 构造一个基本 Config 桩,RuntimeToolExecutor 只需用到它的子集 */ +function makeConfig(): Config { + return { + dangerous_cmd: false, + models: [], + default_model: 'mock', + multi_model_routing: false, + providerConfig: {}, + ui: { + banner: { enabled: false, style: 'particle' }, + theme: 'default', + }, + } as unknown as Config; +} + +/** 注册所有 builtinTools(测试全局隔离,每个测试函数都重注册) */ +function registerBuiltinTools(): void { + for (const t of builtinTools) { + if (!toolRegistry.has(t.name)) toolRegistry.register(t); + } +} + +// ---------- 用例 ①: 5 个高频工具 schema 覆盖 ---------- + +function testHighFreqSchemaCover(): void { + section('① 5 个高频工具 schema 覆盖:合法放行 / 缺必填 / 错类型 / 多余字段'); + + for (const name of HIGH_FREQ_TOOLS) { + const tool = toolRegistry.get(name); + assert(tool !== undefined, `${name} 已注册`); + assert(tool?.zodSchema !== undefined, `${name} 有 zodSchema 字段`); + } + + // ①-1 executeCommand:合法放行 + { + const r = toolRegistry.validateParams('executeCommand', { command: 'ls -la' }); + assert(r.valid, 'executeCommand { command } 合法'); + assert(r.engine === 'zod', `executeCommand 走 zod 引擎 (实际 ${r.engine})`); + } + // ①-2 executeCommand:缺必填 + { + const r = toolRegistry.validateParams('executeCommand', {}); + assert(!r.valid, 'executeCommand {} 缺 command 必填'); + assert(r.issues !== undefined && r.issues.length > 0, 'executeCommand 缺必填返回 issues'); + assert(r.errors !== undefined && r.errors.includes('command'), 'executeCommand 错误文本含 command'); + } + // ①-3 executeCommand:timeout 错类型 + { + const r = toolRegistry.validateParams('executeCommand', { command: 'ls', timeout: '30s' }); + assert(!r.valid, 'executeCommand timeout=字符串 被拦'); + assert((r.issues ?? []).some((i) => i.path === 'timeout'), 'timeout 字段路径命中'); + } + + // ①-4 writeFile:合法放行 + { + const r = toolRegistry.validateParams('writeFile', { path: '/tmp/a', content: 'x' }); + assert(r.valid, 'writeFile { path, content } 合法'); + } + // ①-5 writeFile:缺 content + { + const r = toolRegistry.validateParams('writeFile', { path: '/tmp/a' }); + assert(!r.valid, 'writeFile 缺 content 必填'); + assert((r.issues ?? []).some((i) => i.path === 'content'), 'content 字段路径命中'); + } + // ①-6 writeFile:encoding 不是枚举 + { + const r = toolRegistry.validateParams('writeFile', { + path: '/tmp/a', + content: 'x', + encoding: 'utf-16', + }); + assert(!r.valid, 'writeFile encoding=utf-16 被拦'); + assert((r.issues ?? []).some((i) => i.path === 'encoding'), 'encoding 字段路径命中'); + } + + // ①-7 readFile:合法放行(无 encoding) + { + const r = toolRegistry.validateParams('readFile', { path: '/tmp/a' }); + assert(r.valid, 'readFile { path } 合法'); + } + // ①-8 readFile:path 缺必填 + { + const r = toolRegistry.validateParams('readFile', {}); + assert(!r.valid, 'readFile {} 缺 path'); + assert((r.issues ?? []).some((i) => i.path === 'path'), 'path 字段路径命中'); + } + + // ①-9 searchFiles:合法 + { + const r = toolRegistry.validateParams('searchFiles', { pattern: '*.ts' }); + assert(r.valid, 'searchFiles { pattern } 合法'); + } + // ①-10 searchFiles:ignore 元素错类型 + { + const r = toolRegistry.validateParams('searchFiles', { + pattern: '*.ts', + ignore: ['**/node_modules/**', 123], + }); + assert(!r.valid, 'searchFiles ignore 含非字符串元素被拦'); + assert( + (r.issues ?? []).some((i) => i.path === 'ignore.1'), + `ignore.1 字段路径命中 (实际 ${(r.issues ?? []).map((i) => i.path).join('/')})`, + ); + } + + // ①-11 editFile:合法(discriminated union) + { + const r = toolRegistry.validateParams('editFile', { + path: '/tmp/a', + edits: [{ action: 'replace-lines', start: 1, end: 2, content: 'y' }], + }); + assert(r.valid, 'editFile 合法 replace-lines 修'); + } + // ①-12 editFile:空数组 + { + const r = toolRegistry.validateParams('editFile', { path: '/tmp/a', edits: [] }); + assert(!r.valid, 'editFile edits=[] 空数组被拦'); + assert((r.issues ?? []).some((i) => i.path === 'edits'), 'edits 字段路径命中'); + } + // ①-13 editFile:未知 action 触发 union 失败 + { + const r = toolRegistry.validateParams('editFile', { + path: '/tmp/a', + edits: [{ action: 'unknown-action', start: 1, end: 2 }], + }); + assert(!r.valid, 'editFile 未知 action 被拦'); + assert( + (r.issues ?? []).some((i) => i.path.startsWith('edits.0')), + 'edits.0 字段路径命中(unknown action)', + ); + } + // ①-14 editFile:start 字符串 → 错类型 + { + const r = toolRegistry.validateParams('editFile', { + path: '/tmp/a', + edits: [{ action: 'replace-lines', start: '1', end: 2, content: 'y' }], + }); + assert(!r.valid, 'editFile start=字符串 被拦'); + assert( + (r.issues ?? []).some( + (i) => i.path === 'edits.0.start' || i.path === 'edits.0', + ), + 'edits.0.start 字段路径命中', + ); + } + // ①-15 editFile:replace-lines 缺 content + { + const r = toolRegistry.validateParams('editFile', { + path: '/tmp/a', + edits: [{ action: 'replace-lines', start: 1, end: 2 }], + }); + assert(!r.valid, 'editFile replace-lines 缺 content 被拦'); + assert( + (r.issues ?? []).some((i) => i.path.includes('content') || i.path.includes('edits.0')), + 'edits.0.content 字段路径命中', + ); + } +} + +// ---------- 用例 ②: 错误回灌字段路径 ---------- + +function testFieldPathInErrors(): void { + section('② 错误回灌字段路径:formatError 渲染嵌套路径,方便 LLM 修复'); + + // ②-1 单字段路径 + const r1 = toolRegistry.validateParams('writeFile', { path: '/tmp/a' }); + const txt1 = formatError({ engine: r1.engine, issues: r1.issues, error: r1.errors }); + assert(txt1.includes('[content]'), 'formatError 渲染 [content] 路径'); + assert(txt1.includes('zod'), 'formatError 标注引擎为 zod'); + + // ②-2 嵌套路径 edits.0.start + const r2 = toolRegistry.validateParams('editFile', { + path: '/tmp/a', + edits: [{ action: 'replace-lines', start: 'x', end: 2, content: 'y' }], + }); + const txt2 = formatError({ engine: r2.engine, issues: r2.issues, error: r2.errors }); + assert( + txt2.includes('edits.0.start') || txt2.includes('edits.0'), + `formatError 渲染嵌套路径 (实际摘要: ${txt2.split('\n').slice(0, 4).join(' | ')})`, + ); + assert(txt2.includes('请重新生成'), 'formatError 末尾追加修复提示'); + + // ②-3 仅 error 文本(无 issues) + const txt3 = formatError({ error: '另一个错误' }); + assert(txt3.includes('另一个错误'), 'formatError 容错:仅 error 文本也渲染'); +} + +// ---------- 用例 ③: 自修重试上限 2 次 ---------- + +async function testSelfRepairRetryLimit(): Promise { + section('③ 自修重试上限:连续 3 次失败 → 第 3 次抛错,前 2 次放行继续'); + + // 模拟 llm.ts 的判定逻辑(本函数未导出,故复刻) + const retryMap = new Map(); + + const config = makeConfig(); + const exec2 = new RuntimeToolExecutor(config); + + // 构造畸形 input 让 validateParams 失败 + const badToolCall: ToolCall = { + id: 't1', + type: 'function', + function: { + name: 'executeCommand', + arguments: JSON.stringify({ /* 缺 command */ }), + }, + }; + + // 跑三次,模拟 llm.ts 的循环 + const results: ToolResult[] = []; + for (let i = 0; i < 3; i++) { + const r = await exec2.execute(badToolCall); + results.push(r); + if (isParamValidationFailure(r)) { + const next = (retryMap.get('executeCommand') ?? 0) + 1; + retryMap.set('executeCommand', next); + assert(next <= MAX_TOOL_PARAM_RETRIES + 1, `第 ${i + 1} 次累加 next=${next} 未超阈值前不放行`); + } + } + + const r1 = results[0]!; + const r2 = results[1]!; + const r3 = results[2]!; + assert(r1.success === false, '第 1 次 validateParams 失败'); + assert(String(r1.error).includes('参数验证失败'), '第 1 次 error 含「参数验证失败」'); + assert(String(r1.error).includes('zod'), '第 1 次 error 含 zod 引擎标签'); + assert(r2.success === false, '第 2 次 validateParams 失败'); + assert(r3.success === false, '第 3 次 validateParams 失败'); + + // 模拟 llm.ts 在 next > MAX_TOOL_PARAM_RETRIES 时抛错 + const finalNext = retryMap.get('executeCommand') ?? 0; + assert( + finalNext === MAX_TOOL_PARAM_RETRIES + 1, + `第 3 次后计数 = MAX+1 (实际 ${finalNext}, MAX=${MAX_TOOL_PARAM_RETRIES})`, + ); + // 模拟抛错 + let thrown: Error | null = null; + if (finalNext > MAX_TOOL_PARAM_RETRIES) { + thrown = new Error( + `工具 "executeCommand" 参数校验连续失败 ${finalNext} 次(超过 ${MAX_TOOL_PARAM_RETRIES} 次重试上限),已停止自修。\n` + + `最后一次错误: ${r3.error}`, + ); + } + assert(thrown !== null, '第 3 次失败时 llm.ts 模拟抛出 Error'); + assert( + String(thrown?.message ?? '').includes('超过 2 次重试上限'), + `错误消息含「超过 2 次重试上限」(实际 ${thrown?.message ?? '(null)'})`, + ); + + // 验证 MAX_TOOL_PARAM_RETRIES = 2 + assert(MAX_TOOL_PARAM_RETRIES === 2, `MAX_TOOL_PARAM_RETRIES === 2 (实际 ${MAX_TOOL_PARAM_RETRIES})`); + + // 修复路径:成功后第 4 次重置计数 + const goodCall: ToolCall = { + id: 'g1', + type: 'function', + function: { + name: 'executeCommand', + arguments: JSON.stringify({ command: 'echo hello' }), + }, + }; + const ok = await exec2.execute(goodCall); + assert(ok.success === true, '合法参数 executeCommand 成功'); + // 模拟 llm.ts 删除计数的逻辑 + retryMap.delete('executeCommand'); + assert(retryMap.get('executeCommand') === undefined, '成功后 retryMap 计数被删除'); +} + +// ---------- 用例 ④: 低风险工具走 JSONSchema(ajv)路径 ---------- + +function testLowRiskUsesJsonSchema(): void { + section('④ 低风险工具(getCurrentDateTime/getCurrentDirectory/getGitInfo)走 ajv,不被 zod 拦'); + + for (const name of LOW_RISK_TOOLS) { + const tool = toolRegistry.get(name); + assert(tool !== undefined, `${name} 已注册`); + assert(tool?.zodSchema === undefined, `${name} 无 zodSchema(纯 JSONSchema 兜底)`); + const r1 = toolRegistry.validateParams(name, {}); + assert(r1.valid, `${name} 合法空参数放行`); + assert(r1.engine === 'ajv', `${name} 走 ajv 引擎`); + const r2 = toolRegistry.validateParams(name, 'a string'); + assert(!r2.valid, `${name} 错类型被 ajv 拦`); + assert(r2.engine === 'ajv', `${name} 错类型仍走 ajv`); + } + + // 反向:zod 工具不会退化到 ajv + const exec = toolRegistry.get('executeCommand')!; + assert(exec.zodSchema !== undefined, 'executeCommand.zodSchema 字段存在'); + assert( + typeof (exec.parameters as object) === 'object' + && (exec.parameters as { type?: string }).type === 'object', + 'executeCommand parameters 字段是 JSONSchema 对象', + ); +} + +// ---------- 用例 ⑤: 6 处既有 zod 导入仍能工作 ---------- + +async function testExistingZodImports(): Promise { + section('⑤ 6 处既有 `import { z } from \'zod\'` 仍能编译'); + + // 当前 build 中未排除的 3 个 v3 入口:应能直接 import() 成功 + const activeImports: Array<{ name: string; module: string }> = [ + { name: 'acpModelUtils.ts', module: '../src/utils/acpModelUtils.js' }, + { name: 'FileCommandLoader.ts', module: '../src/services/FileCommandLoader.js' }, + { name: 'markdown-command-parser.ts', module: '../src/services/markdown-command-parser.js' }, + ]; + for (const entry of activeImports) { + let mod: any; + try { + mod = await import(entry.module); + } catch (err) { + assert(false, `${entry.name} 模块加载失败: ${err instanceof Error ? err.message : String(err)}`); + continue; + } + assert(mod !== undefined, `${entry.name} 模块加载成功(zod v4 包兼容 v3 入口)`); + } + + // acp-integration 子目录的 3 个模块:运行时不在 build 内,但源码仍走 v3 入口 + // 通过 spawnSync `bun --print` 跑单文件 import 来验证编译/类型 + const { spawnSync } = await import('node:child_process'); + const acpFiles = [ + 'src/acp-integration/acpAgent.ts', + 'src/acp-integration/session/Session.ts', + 'src/acp-integration/session/SubAgentTracker.ts', + ]; + for (const f of acpFiles) { + // -e 跑一小段:从该文件静态 import 一次 `z` 符号,证明导入语法 + v4 兼容 + const probe = `import { z } from 'zod'; const _ok = z.string().safeParse('x'); console.log('ok');`; + const res = spawnSync(process.execPath, ['-e', probe], { + cwd: process.cwd(), + encoding: 'utf-8', + }); + assert(res.status === 0, `${f} 静态 zod v3 入口依然解析(via spawn)`); + } + + // v3 入口(zod 默认)实测一个 schema + const v3z = (await import('zod')).z; + const v3Schema = v3z.object({ a: v3z.string() }); + assert(v3Schema.safeParse({ a: 'hi' }).success, 'v3 zod 入口 (safeParse) 仍工作'); + assert(!v3Schema.safeParse({ a: 1 }).success, 'v3 zod 入口 (类型错误) 仍拦截'); + + // v4 入口(zod/v4)实测一个 schema + const v4Schema = z.object({ a: z.string() }); + assert(v4Schema.safeParse({ a: 'hi' }).success, 'v4 zod 入口 (zod/v4) 仍工作'); + assert(!v4Schema.safeParse({ a: 1 }).success, 'v4 zod 入口 (类型错误) 仍拦截'); +} + +// ---------- 用例 ⑥(toPublicSchema / cache) 补充 ---------- + +function testSchemaFromZod(): void { + section('⑥ schemaFromZod:zod → JSONSchema,缓存命中,toPublicSchema 二选一'); + + const exec = toolRegistry.get('executeCommand')!; + const pub = toPublicSchema(exec); + assert(pub.type === 'object', 'toPublicSchema 返回 object 顶层'); + assert((pub.properties as Record).command !== undefined, 'JSONSchema 含 command 字段'); + assert(Array.isArray(pub.required) && pub.required.includes('command'), 'JSONSchema required 含 command'); + + // 缓存:同 schema 再调一次,引用应一致 + const pub2 = zodToJsonSchema(exec.zodSchema!); + assert(pub === pub2, 'zodToJsonSchema 缓存命中(返回同一引用)'); + + // 纯 JSONSchema 工具:toPublicSchema 原样返回 + const dt = toolRegistry.get('getCurrentDateTime')!; + const dtPub = toPublicSchema(dt); + assert(dtPub === dt.parameters, 'toPublicSchema 对纯 JSONSchema 工具原样返回 parameters'); +} + +// ---------- 用例 ⑦ parseZodSchema / parseJsonSchema 直接路径 ---------- + +function testParseSchemaDirect(): void { + section('⑦ zodAdapter:parseZodSchema / parseJsonSchema 直接入口'); + + // zod 路径 + const s = z.object({ x: z.string() }); + const r1 = parseZodSchema(s, { x: 'ok' }); + assert(r1.valid && r1.engine === 'zod', 'parseZodSchema 走 zod 引擎'); + const r2 = parseZodSchema(s, { x: 1 }); + assert(!r2.valid && r2.engine === 'zod', 'parseZodSchema 拦截错类型'); + assert(r2.issues && r2.issues.length > 0 && r2.issues[0]!.path === 'x', 'parseZodSchema 给出字段路径'); + + // JSONSchema 路径 + const j = { type: 'object', properties: { y: { type: 'number' } }, required: ['y'] } as const; + const r3 = parseJsonSchema(j, { y: 1 }); + assert(r3.valid && r3.engine === 'ajv', 'parseJsonSchema 走 ajv 引擎'); + const r4 = parseJsonSchema(j, { y: 'one' }); + assert(!r4.valid && r4.engine === 'ajv', 'parseJsonSchema 拦截 JSONSchema 错类型'); +} + +// ---------- 主入口 ---------- + +async function main(): Promise { + console.log('🧪 test-issue-009 — Zod v4 运行时 Schema 校验(LLM tool_call 参数)\n'); + + // 测试在独立 registry 副本上跑,避免污染全局 + // builtinTools 因模块副作用已包含 zodSchema + registerBuiltinTools(); + + try { + testHighFreqSchemaCover(); + testFieldPathInErrors(); + await testSelfRepairRetryLimit(); + testLowRiskUsesJsonSchema(); + await testExistingZodImports(); + testSchemaFromZod(); + testParseSchemaDirect(); + } catch (err) { + console.error('uncaught:', err); + failures.push('uncaught: ' + (err instanceof Error ? err.message : String(err))); + failed++; + } + + console.log(`\n────────────────────────────`); + console.log(`PASS: ${passed} FAIL: ${failed}`); + if (failed > 0) { + console.log('\n失败明细:'); + failures.forEach((m) => console.log(` - ${m}`)); + process.exit(1); + } else { + process.exit(0); + } +} + +void main(); diff --git a/test-case/test-list.md b/test-case/test-list.md index b116bd53b8e1a06cf857b460c9aa6cf8a580ec12..c69a3383480689b83d9fc7baa7937dcfbf6f4bfd 100644 --- a/test-case/test-list.md +++ b/test-case/test-list.md @@ -7,8 +7,8 @@ ## 全量回归 ```bash -# issue 回归套件(当前基线 168 断言) -for t in 001 002 003 004 005 012 019; do bun run test-case/test-issue-$t.ts || exit 1; done +# issue 回归套件(当前基线 266 断言) +for t in 001 002 003 004 005 009 010 012 019; do bun run test-case/test-issue-$t.ts || exit 1; done ``` ## 清单(按 issue 编号排序) @@ -22,6 +22,7 @@ for t in 001 002 003 004 005 012 019; do bun run test-case/test-issue-$t.ts || e | `test-issue-004.ts` | Feature Flag + 构建期 DCE:flag 开关、GrowthBookLocal、acp-integration 剥离字节 0 | 构建/runtime feature(build.ts、runtime/feature) | issue #4(IK8MWJ)/ PR !5 | | `test-issue-005.ts` | Workspace Backend 收敛守卫:daemon 不得直接 import *Backend 实现(grep + tsc 两层) | workspace 解耦(daemon、runtime/workspace) | issue #5(IK8MWK)/ PR !6 | | `test-issue-010.ts` | ripgrep 子进程替换 glob:`rg --json` NDJSON 解析、空 PATH 自动降级、ignore 列表对齐、CI 基准 | 工具性能(utils/ripgrepRunner、tools/builtin/searchFiles) | issue #10(IK8MWP)/ PR !11 | +| `test-issue-009.ts` | Zod v4 运行时校验:5 个高频工具 schema 覆盖 + 字段路径错误回灌 + 自修重试上限 2 + 低风险工具走 ajv + zod v4/v3 双入口兼容 | 工具系统(tools/zodAdapter、tools/schemaFromZod、runtime/tools/toolResultFormatter、core/llm) | issue #9(IK8MWO)/ PR !12 | | `test-issue-019.ts` | karpathy-wiki-new bundled skill:SKILL.md 契约、scaffold 执行器、listBundledSkills、dist 打包 | 内置 skills(skills/bundled) | issue #19(IK8MWL)/ PR !7 | | `test-issue-012.ts` | token 预算接通 TUI:getUsage 边界、ChatStreamEvent.budget_update 类型联合、TokenBudgetBar 字符串、联调事件序列 | runtime/agent/tokenBudget → types/chatStream → UI/Footer | issue #12(IK8MWR)/ PR !8 | | `test-model.ts` | 手动入口:模型连通性 + 速度检查(等价 `alice --test-model`);实现位于 `src/utils/testModel.ts` | 模型诊断(utils/testModel) | 历史 dev 脚本(无 PR);2026-08-15 修复为可运行薄壳 |