From 8dbcaadfaf68c6ffb8218dfb283ca686d00ad0dc Mon Sep 17 00:00:00 2001 From: gog5-ops Date: Sun, 26 Apr 2026 00:20:59 +0000 Subject: [PATCH] fix(agents): use jsonSchema helper instead of zod for tool inputSchema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit zod 4 + AI SDK 6.x 下 tool({ inputSchema: z.object(...) }) 经过 prepareToolsAndToolChoice() 处理后 schema 被错误转换为 {"properties":{}, "additionalProperties":false},所有参数定义被剥光, 导致 LLM 工具调用乱传参/静默失败/死循环。 修复方案:改用 AI SDK 官方 jsonSchema() helper 替代 z.object(), 绕过出 bug 的 zod 转换路径。不动 node_modules, 未来 SDK 升级也不会回归。 改动 9 个文件,全部为 z.object → jsonSchema 替换: - src/agents/scriptAgent/{tools.ts, index.ts} - src/agents/productionAgent/{tools.ts, index.ts} - src/utils/agent/{memory.ts, skillsTools.ts} - src/routes/script/extractAssets.ts - src/routes/setting/vendorConfig/modelTest.ts - src/routes/cornerScape/batchBindAudio.ts E2E 验证: - 9 个工具 inputSchema.jsonSchema.properties 字段完整保留 - storySkeleton 子代理收到 get_novel_events([1,2,3,4,5]) 完整章节范围 (之前 schema 损坏时只能盲传 [1],导致死循环) - 故事骨架 / 改编策略 / 分镜面板等多 sub-agent 不再因 schema 损坏卡死 Related upstream: vercel/ai#13460, vercel/ai#12020 Fixes: HBAI-Ltd/Toonflow-app#80, #94, #121, #122 类似症状 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/agents/productionAgent/index.ts | 12 ++-- src/agents/productionAgent/tools.ts | 70 ++++++++++++++------ src/agents/scriptAgent/index.ts | 12 ++-- src/agents/scriptAgent/tools.ts | 42 +++++++++--- src/routes/cornerScape/batchBindAudio.ts | 26 ++++++-- src/routes/script/extractAssets.ts | 43 +++++++++--- src/routes/setting/vendorConfig/modelTest.ts | 11 ++- src/utils/agent/memory.ts | 12 ++-- src/utils/agent/skillsTools.ts | 21 ++++-- 9 files changed, 181 insertions(+), 68 deletions(-) diff --git a/src/agents/productionAgent/index.ts b/src/agents/productionAgent/index.ts index 0edd578..f7efa7e 100644 --- a/src/agents/productionAgent/index.ts +++ b/src/agents/productionAgent/index.ts @@ -1,6 +1,5 @@ import { Socket } from "socket.io"; -import { tool } from "ai"; -import { z } from "zod"; +import { tool, jsonSchema } from "ai"; import u from "@/utils"; import Memory from "@/utils/agent/memory"; import { createSkillTools, parseFrontmatter, scanSkills, useSkill } from "@/utils/agent/skillsTools"; @@ -138,8 +137,13 @@ async function createSubAgent(parentCtx: AgentContext) { return fullResponse; } - const promptInput = z.object({ - prompt: z.string().describe("交给子Agent的任务简约描述,100字以内"), + const promptInput = jsonSchema<{ prompt: string }>({ + type: "object", + properties: { + prompt: { type: "string", description: "交给子Agent的任务简约描述,100字以内" }, + }, + required: ["prompt"], + additionalProperties: false, }); const projectInfo = await u.db("o_project").where("id", resTool.data.projectId).first(); diff --git a/src/agents/productionAgent/tools.ts b/src/agents/productionAgent/tools.ts index bbf28d4..03b5101 100644 --- a/src/agents/productionAgent/tools.ts +++ b/src/agents/productionAgent/tools.ts @@ -1,4 +1,4 @@ -import { tool, Tool } from "ai"; +import { tool, jsonSchema, Tool } from "ai"; import { z } from "zod"; import _ from "lodash"; import ResTool from "@/socket/resTool"; @@ -52,7 +52,7 @@ export const flowDataSchema = z.object({ export type FlowData = z.infer; -const keySchema = z.enum(Object.keys(flowDataSchema.shape) as [keyof FlowData, ...Array]); +const flowDataKeys = Object.keys(flowDataSchema.shape) as (keyof FlowData)[]; const flowDataKeyLabels = Object.fromEntries( Object.entries(flowDataSchema.shape).map(([key, schema]) => [key, (schema as z.ZodTypeAny).description ?? key]), ) as Record; @@ -69,8 +69,13 @@ export default (toolCpnfig: ToolConfig) => { const tools: Record = { get_flowData: tool({ description: "获取工作区数据", - inputSchema: z.object({ - key: keySchema.describe("数据key"), + inputSchema: jsonSchema<{ key: keyof FlowData }>({ + type: "object", + properties: { + key: { type: "string", enum: flowDataKeys as string[], description: "数据key" }, + }, + required: ["key"], + additionalProperties: false, }), execute: async ({ key }) => { const thinking = msg.thinking(`正在获取${flowDataKeyLabels[key]}工作区数据...`); @@ -84,18 +89,24 @@ export default (toolCpnfig: ToolConfig) => { }), add_deriveAsset: tool({ description: "新增或更新衍生资产", - inputSchema: z.object({ - assetsId: z.number().describe("关联的资产ID"), - id: z.preprocess( - (val) => { - if (val === "null" || val === "" || val === undefined) return null; - return val; - }, - z.number().nullable().describe("衍生资产ID,如果新增则为空")), - name: z.string().describe("衍生资产名称"), - desc: z.string().describe("衍生资产描述"), + inputSchema: jsonSchema<{ assetsId: number; id: number | null; name: string; desc: string }>({ + type: "object", + properties: { + assetsId: { type: "number", description: "关联的资产ID" }, + id: { type: ["number", "null"], description: "衍生资产ID,如果新增则为空" }, + name: { type: "string", description: "衍生资产名称" }, + desc: { type: "string", description: "衍生资产描述" }, + }, + required: ["assetsId", "id", "name", "desc"], + additionalProperties: false, }), - execute: async (deriveAsset) => { + execute: async (raw) => { + // 容错:LLM 偶尔传 "null" 字符串或空串,统一规范为 null + const idRaw = raw.id as unknown; + const normalizedId = + idRaw === "null" || idRaw === "" || idRaw === undefined ? null : (idRaw as number | null); + const deriveAsset = { ...raw, id: normalizedId }; + const thinking = msg.thinking("正在操作资产..."); const { projectId, scriptId } = resTool.data; const startTime = Date.now(); @@ -128,9 +139,14 @@ export default (toolCpnfig: ToolConfig) => { }), del_deriveAsset: tool({ description: "删除衍生资产", - inputSchema: z.object({ - assetsId: z.number().describe("关联的资产ID"), - id: z.number().describe("衍生资产ID"), + inputSchema: jsonSchema<{ assetsId: number; id: number }>({ + type: "object", + properties: { + assetsId: { type: "number", description: "关联的资产ID" }, + id: { type: "number", description: "衍生资产ID" }, + }, + required: ["assetsId", "id"], + additionalProperties: false, }), execute: async ({ assetsId, id }) => { const thinking = msg.thinking("正在操作资产..."); @@ -146,8 +162,13 @@ export default (toolCpnfig: ToolConfig) => { }), generate_deriveAsset: tool({ description: "生成衍生资产图片", - inputSchema: z.object({ - ids: z.array(z.number()).describe("需要生成的 衍生资产ID"), + inputSchema: jsonSchema<{ ids: number[] }>({ + type: "object", + properties: { + ids: { type: "array", items: { type: "number" }, description: "需要生成的 衍生资产ID" }, + }, + required: ["ids"], + additionalProperties: false, }), execute: async ({ ids }) => { const thinking = msg.thinking("正在生成衍生资产..."); @@ -168,8 +189,13 @@ export default (toolCpnfig: ToolConfig) => { }), generate_storyboard: tool({ description: "生成分镜图片", - inputSchema: z.object({ - ids: z.array(z.number()).describe("必须获取真实的分镜ID,支持批量生成"), + inputSchema: jsonSchema<{ ids: number[] }>({ + type: "object", + properties: { + ids: { type: "array", items: { type: "number" }, description: "必须获取真实的分镜ID,支持批量生成" }, + }, + required: ["ids"], + additionalProperties: false, }), execute: async ({ ids }) => { const thinking = msg.thinking("正在生成分镜..."); diff --git a/src/agents/scriptAgent/index.ts b/src/agents/scriptAgent/index.ts index d8f19d6..a0d5764 100644 --- a/src/agents/scriptAgent/index.ts +++ b/src/agents/scriptAgent/index.ts @@ -1,6 +1,5 @@ import { Socket } from "socket.io"; -import { tool } from "ai"; -import { z } from "zod"; +import { tool, jsonSchema } from "ai"; import u from "@/utils"; import Memory from "@/utils/agent/memory"; import useTools from "@/agents/scriptAgent/tools"; @@ -133,8 +132,13 @@ function createSubAgent(parentCtx: AgentContext) { return fullResponse; } - const promptInput = z.object({ - prompt: z.string().describe("交给子Agent的任务简约描述,100字以内"), + const promptInput = jsonSchema<{ prompt: string }>({ + type: "object", + properties: { + prompt: { type: "string", description: "交给子Agent的任务简约描述,100字以内" }, + }, + required: ["prompt"], + additionalProperties: false, }); const run_sub_agent_storySkeleton = tool({ diff --git a/src/agents/scriptAgent/tools.ts b/src/agents/scriptAgent/tools.ts index adeece6..8ca7bde 100644 --- a/src/agents/scriptAgent/tools.ts +++ b/src/agents/scriptAgent/tools.ts @@ -1,4 +1,4 @@ -import { tool, Tool } from "ai"; +import { tool, jsonSchema, Tool } from "ai"; import u from "@/utils"; import { z } from "zod"; import _ from "lodash"; @@ -16,7 +16,7 @@ export const planData = z.object({ export type planData = z.infer; -const keySchema = z.enum(Object.keys(planData.shape) as [keyof planData, ...Array]); +const planDataKeys = Object.keys(planData.shape) as (keyof planData)[]; const planDataKeyLabels = Object.fromEntries( Object.entries(planData.shape).map(([key, schema]) => [key, (schema as z.ZodTypeAny).description ?? key]), ) as Record; @@ -33,8 +33,13 @@ export default (toolCpnfig: ToolConfig) => { const tools: Record = { get_novel_events: tool({ description: "获取章节事件", - inputSchema: z.object({ - chapterIndexs: z.array(z.number()).describe("章节的编号"), + inputSchema: jsonSchema<{ chapterIndexs: number[] }>({ + type: "object", + properties: { + chapterIndexs: { type: "array", items: { type: "number" }, description: "章节的编号" }, + }, + required: ["chapterIndexs"], + additionalProperties: false, }), execute: async ({ chapterIndexs }) => { console.log("[tools] get_novel_events", chapterIndexs); @@ -45,7 +50,7 @@ export default (toolCpnfig: ToolConfig) => { .select("id", "chapterIndex as index", "reel", "chapter", "chapterData", "event", "eventState") .whereIn("chapterIndex", chapterIndexs); thinking.appendText("正在查询章节编号: " + chapterIndexs.join(",")); - const eventString = data.map((i: any) => [`第${i.index}章,标题:${i.chapter},事件:${i.event}`].join("\n")).join("\n"); + const eventString = data.map((i: any) => [`第${i.index}章,标题:${i.chapter},事件:${i.event}`].join("\n")).join("\n"); thinking.appendText("查询结果:\n" + eventString); thinking.updateTitle("查询章节事件完成"); thinking.complete(); @@ -54,8 +59,13 @@ export default (toolCpnfig: ToolConfig) => { }), get_planData: tool({ description: "获取工作区数据", - inputSchema: z.object({ - key: keySchema.describe("数据key"), + inputSchema: jsonSchema<{ key: keyof planData }>({ + type: "object", + properties: { + key: { type: "string", enum: planDataKeys as string[], description: "数据key" }, + }, + required: ["key"], + additionalProperties: false, }), execute: async ({ key }) => { console.log("[tools] get_planData", key); @@ -69,8 +79,13 @@ export default (toolCpnfig: ToolConfig) => { }), get_novel_text: tool({ description: "获取小说章节原始文本内容", - inputSchema: z.object({ - chapterIndex: z.string().describe("章节编号"), + inputSchema: jsonSchema<{ chapterIndex: string }>({ + type: "object", + properties: { + chapterIndex: { type: "string", description: "章节编号" }, + }, + required: ["chapterIndex"], + additionalProperties: false, }), execute: async ({ chapterIndex }) => { console.log("[tools] get_novel_text", "[tools] get_novel_text", chapterIndex); @@ -85,8 +100,13 @@ export default (toolCpnfig: ToolConfig) => { }), get_script_content: tool({ description: "获取剧本本内容", - inputSchema: z.object({ - ids: z.array(z.string()).describe("脚本id"), + inputSchema: jsonSchema<{ ids: string[] }>({ + type: "object", + properties: { + ids: { type: "array", items: { type: "string" }, description: "脚本id" }, + }, + required: ["ids"], + additionalProperties: false, }), execute: async ({ ids }) => { console.log("[tools] get_script_content", "[tools] get_script_content", ids); diff --git a/src/routes/cornerScape/batchBindAudio.ts b/src/routes/cornerScape/batchBindAudio.ts index 6da14ca..98dd5f1 100644 --- a/src/routes/cornerScape/batchBindAudio.ts +++ b/src/routes/cornerScape/batchBindAudio.ts @@ -3,7 +3,7 @@ import u from "@/utils"; import { z } from "zod"; import { success } from "@/lib/responseFormat"; import { validateFields } from "@/middleware/middleware"; -import { tool } from "ai"; +import { tool, jsonSchema } from "ai"; const router = express.Router(); // 获取资产 @@ -30,11 +30,25 @@ export default router.post( try { const resultTool = tool({ description: "返回结果时必须调用这个工具", - inputSchema: z.object({ - result: z.array(z.object({ - id: z.number(), - audioIds: z.array(z.number()).describe("适配的音频id 无适配内容可以为 空数组") - })).describe("适配的音色列表,id为资产id,audioIds为适配的音频id 无适配内容可以为 空数组") + inputSchema: jsonSchema<{ result: { id: number; audioIds: number[] }[] }>({ + type: "object", + properties: { + result: { + type: "array", + description: "适配的音色列表,id为资产id,audioIds为适配的音频id 无适配内容可以为 空数组", + items: { + type: "object", + properties: { + id: { type: "number" }, + audioIds: { type: "array", items: { type: "number" }, description: "适配的音频id 无适配内容可以为 空数组" }, + }, + required: ["id", "audioIds"], + additionalProperties: false, + }, + }, + }, + required: ["result"], + additionalProperties: false, }), execute: async ({ result }) => { console.log("[tools] extractAssets result", result); diff --git a/src/routes/script/extractAssets.ts b/src/routes/script/extractAssets.ts index 4f65df3..d2effae 100644 --- a/src/routes/script/extractAssets.ts +++ b/src/routes/script/extractAssets.ts @@ -4,7 +4,7 @@ import { z } from "zod"; import { error, success } from "@/lib/responseFormat"; import { validateFields } from "@/middleware/middleware"; import { useSkill } from "@/utils/agent/skillsTools"; -import { tool } from "ai"; +import { tool, jsonSchema } from "ai"; import { o_script } from "@/types/database"; const router = express.Router(); @@ -188,13 +188,40 @@ export default router.post( try { const resultTool = tool({ description: "返回结果时必须调用这个工具", - inputSchema: z.object({ - newAssets: z - .array(NewAssetSchema) - .describe("新发现的资产列表(不在已有资产列表中的),需要完整的 prompt、name、desc、type 和使用该资产的 scriptIds"), - existingAssetRefs: z - .array(ExistingAssetRefSchema) - .describe("已有资产的引用列表(在已有资产列表中已存在的),只需给出资产名称和使用该资产的 scriptIds"), + inputSchema: jsonSchema<{ newAssets: NewAsset[]; existingAssetRefs: ExistingAssetRef[] }>({ + type: "object", + properties: { + newAssets: { + type: "array", + description: "新发现的资产列表(不在已有资产列表中的),需要完整的 prompt、name、desc、type 和使用该资产的 scriptIds", + items: { + type: "object", + properties: { + name: { type: "string", description: "资产名称,仅为名称不做其他任何表述" }, + desc: { type: "string", description: "资产描述" }, + type: { type: "string", enum: ["role", "tool", "scene"], description: "资产类型" }, + scriptIds: { type: "array", items: { type: "number" }, description: "使用该资产的剧本id数组" }, + }, + required: ["name", "desc", "type", "scriptIds"], + additionalProperties: false, + }, + }, + existingAssetRefs: { + type: "array", + description: "已有资产的引用列表(在已有资产列表中已存在的),只需给出资产名称和使用该资产的 scriptIds", + items: { + type: "object", + properties: { + name: { type: "string", description: "已有资产的名称,必须与已有资产列表中的名称完全一致" }, + scriptIds: { type: "array", items: { type: "number" }, description: "使用该资产的剧本id数组" }, + }, + required: ["name", "scriptIds"], + additionalProperties: false, + }, + }, + }, + required: ["newAssets", "existingAssetRefs"], + additionalProperties: false, }), execute: async ({ newAssets, existingAssetRefs }) => { diff --git a/src/routes/setting/vendorConfig/modelTest.ts b/src/routes/setting/vendorConfig/modelTest.ts index a789459..1ed3cf0 100644 --- a/src/routes/setting/vendorConfig/modelTest.ts +++ b/src/routes/setting/vendorConfig/modelTest.ts @@ -3,7 +3,7 @@ import { success, error } from "@/lib/responseFormat"; import { validateFields } from "@/middleware/middleware"; import u from "@/utils"; import { z } from "zod"; -import { tool } from "ai"; +import { tool, jsonSchema } from "ai"; const router = express.Router(); // 检查语言模型 @@ -57,8 +57,13 @@ export default router.post( const getWeatherTool = tool({ description: "Get the weather in a location", - inputSchema: z.object({ - location: z.string().describe("The location to get the weather for"), + inputSchema: jsonSchema<{ location: string }>({ + type: "object", + properties: { + location: { type: "string", description: "The location to get the weather for" }, + }, + required: ["location"], + additionalProperties: false, }), execute: async ({ location }) => { return { diff --git a/src/utils/agent/memory.ts b/src/utils/agent/memory.ts index 1ba2413..c875bc7 100644 --- a/src/utils/agent/memory.ts +++ b/src/utils/agent/memory.ts @@ -2,8 +2,7 @@ import u from "@/utils"; import { v4 as uuidv4 } from "uuid"; import { getEmbedding, cosineSimilarity } from "./embedding"; import type { memories as MemoryRow } from "@/types/database"; -import { tool } from "ai"; -import { z } from "zod"; +import { tool, jsonSchema } from "ai"; // ── 可调配置默认值 ── const DEFAULTS: { @@ -201,8 +200,13 @@ class Memory { return { deepRetrieve: tool({ description: "深度检索记忆:当你需要回忆与某个关键词相关的详细历史信息时使用此工具", - inputSchema: z.object({ - keyword: z.string().describe("要检索的关键词"), + inputSchema: jsonSchema<{ keyword: string }>({ + type: "object", + properties: { + keyword: { type: "string", description: "要检索的关键词" }, + }, + required: ["keyword"], + additionalProperties: false, }), execute: async ({ keyword }) => { const results = await this.deepRetrieve(keyword); diff --git a/src/utils/agent/skillsTools.ts b/src/utils/agent/skillsTools.ts index f5ae0be..3531eb9 100644 --- a/src/utils/agent/skillsTools.ts +++ b/src/utils/agent/skillsTools.ts @@ -1,5 +1,4 @@ -import { tool } from "ai"; -import { z } from "zod"; +import { tool, jsonSchema } from "ai"; import path from "path"; import isPathInside from "is-path-inside"; import getPath from "@/utils/getPath"; @@ -185,8 +184,13 @@ export function createSkillTools(skills: { name: string; description: string }[] return { activate_skill: tool({ description: `激活一个技能,加载其完整指令和捆绑资源列表到上下文。可用技能:${skillNames.join(", ")}`, - inputSchema: z.object({ - name: z.enum(skillNames as [string, ...string[]]).describe("要激活的技能名称"), + inputSchema: jsonSchema<{ name: string }>({ + type: "object", + properties: { + name: { type: "string", enum: skillNames, description: "要激活的技能名称" }, + }, + required: ["name"], + additionalProperties: false, }), execute: async ({ name }) => { if (activated.has(name)) { @@ -222,8 +226,13 @@ export function createSkillTools(skills: { name: string; description: string }[] }), read_skill_file: tool({ description: "读取已激活技能目录下的资源文件。传入 activate_skill 返回的 skill_resources 中的文件路径。", - inputSchema: z.object({ - filePath: z.string().describe("资源文件的相对路径,来自 activate_skill 返回的 skill_resources"), + inputSchema: jsonSchema<{ filePath: string }>({ + type: "object", + properties: { + filePath: { type: "string", description: "资源文件的相对路径,来自 activate_skill 返回的 skill_resources" }, + }, + required: ["filePath"], + additionalProperties: false, }), execute: async ({ filePath }) => { const normalizedInputPath = toUnixPath(filePath).trim();