fix(agents): use jsonSchema helper instead of zod for tool inputSchema

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) <noreply@anthropic.com>
This commit is contained in:
gog5-ops 2026-04-26 00:20:59 +00:00
parent 4347877c48
commit 8dbcaadfaf
9 changed files with 181 additions and 68 deletions

View File

@ -1,6 +1,5 @@
import { Socket } from "socket.io"; import { Socket } from "socket.io";
import { tool } from "ai"; import { tool, jsonSchema } from "ai";
import { z } from "zod";
import u from "@/utils"; import u from "@/utils";
import Memory from "@/utils/agent/memory"; import Memory from "@/utils/agent/memory";
import { createSkillTools, parseFrontmatter, scanSkills, useSkill } from "@/utils/agent/skillsTools"; import { createSkillTools, parseFrontmatter, scanSkills, useSkill } from "@/utils/agent/skillsTools";
@ -138,8 +137,13 @@ async function createSubAgent(parentCtx: AgentContext) {
return fullResponse; return fullResponse;
} }
const promptInput = z.object({ const promptInput = jsonSchema<{ prompt: string }>({
prompt: z.string().describe("交给子Agent的任务简约描述100字以内"), 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(); const projectInfo = await u.db("o_project").where("id", resTool.data.projectId).first();

View File

@ -1,4 +1,4 @@
import { tool, Tool } from "ai"; import { tool, jsonSchema, Tool } from "ai";
import { z } from "zod"; import { z } from "zod";
import _ from "lodash"; import _ from "lodash";
import ResTool from "@/socket/resTool"; import ResTool from "@/socket/resTool";
@ -52,7 +52,7 @@ export const flowDataSchema = z.object({
export type FlowData = z.infer<typeof flowDataSchema>; export type FlowData = z.infer<typeof flowDataSchema>;
const keySchema = z.enum(Object.keys(flowDataSchema.shape) as [keyof FlowData, ...Array<keyof FlowData>]); const flowDataKeys = Object.keys(flowDataSchema.shape) as (keyof FlowData)[];
const flowDataKeyLabels = Object.fromEntries( const flowDataKeyLabels = Object.fromEntries(
Object.entries(flowDataSchema.shape).map(([key, schema]) => [key, (schema as z.ZodTypeAny).description ?? key]), Object.entries(flowDataSchema.shape).map(([key, schema]) => [key, (schema as z.ZodTypeAny).description ?? key]),
) as Record<keyof FlowData, string>; ) as Record<keyof FlowData, string>;
@ -69,8 +69,13 @@ export default (toolCpnfig: ToolConfig) => {
const tools: Record<string, Tool> = { const tools: Record<string, Tool> = {
get_flowData: tool({ get_flowData: tool({
description: "获取工作区数据", description: "获取工作区数据",
inputSchema: z.object({ inputSchema: jsonSchema<{ key: keyof FlowData }>({
key: keySchema.describe("数据key"), type: "object",
properties: {
key: { type: "string", enum: flowDataKeys as string[], description: "数据key" },
},
required: ["key"],
additionalProperties: false,
}), }),
execute: async ({ key }) => { execute: async ({ key }) => {
const thinking = msg.thinking(`正在获取${flowDataKeyLabels[key]}工作区数据...`); const thinking = msg.thinking(`正在获取${flowDataKeyLabels[key]}工作区数据...`);
@ -84,18 +89,24 @@ export default (toolCpnfig: ToolConfig) => {
}), }),
add_deriveAsset: tool({ add_deriveAsset: tool({
description: "新增或更新衍生资产", description: "新增或更新衍生资产",
inputSchema: z.object({ inputSchema: jsonSchema<{ assetsId: number; id: number | null; name: string; desc: string }>({
assetsId: z.number().describe("关联的资产ID"), type: "object",
id: z.preprocess( properties: {
(val) => { assetsId: { type: "number", description: "关联的资产ID" },
if (val === "null" || val === "" || val === undefined) return null; id: { type: ["number", "null"], description: "衍生资产ID,如果新增则为空" },
return val; name: { type: "string", description: "衍生资产名称" },
}, desc: { type: "string", description: "衍生资产描述" },
z.number().nullable().describe("衍生资产ID,如果新增则为空")), },
name: z.string().describe("衍生资产名称"), required: ["assetsId", "id", "name", "desc"],
desc: z.string().describe("衍生资产描述"), 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 thinking = msg.thinking("正在操作资产...");
const { projectId, scriptId } = resTool.data; const { projectId, scriptId } = resTool.data;
const startTime = Date.now(); const startTime = Date.now();
@ -128,9 +139,14 @@ export default (toolCpnfig: ToolConfig) => {
}), }),
del_deriveAsset: tool({ del_deriveAsset: tool({
description: "删除衍生资产", description: "删除衍生资产",
inputSchema: z.object({ inputSchema: jsonSchema<{ assetsId: number; id: number }>({
assetsId: z.number().describe("关联的资产ID"), type: "object",
id: z.number().describe("衍生资产ID"), properties: {
assetsId: { type: "number", description: "关联的资产ID" },
id: { type: "number", description: "衍生资产ID" },
},
required: ["assetsId", "id"],
additionalProperties: false,
}), }),
execute: async ({ assetsId, id }) => { execute: async ({ assetsId, id }) => {
const thinking = msg.thinking("正在操作资产..."); const thinking = msg.thinking("正在操作资产...");
@ -146,8 +162,13 @@ export default (toolCpnfig: ToolConfig) => {
}), }),
generate_deriveAsset: tool({ generate_deriveAsset: tool({
description: "生成衍生资产图片", description: "生成衍生资产图片",
inputSchema: z.object({ inputSchema: jsonSchema<{ ids: number[] }>({
ids: z.array(z.number()).describe("需要生成的 衍生资产ID"), type: "object",
properties: {
ids: { type: "array", items: { type: "number" }, description: "需要生成的 衍生资产ID" },
},
required: ["ids"],
additionalProperties: false,
}), }),
execute: async ({ ids }) => { execute: async ({ ids }) => {
const thinking = msg.thinking("正在生成衍生资产..."); const thinking = msg.thinking("正在生成衍生资产...");
@ -168,8 +189,13 @@ export default (toolCpnfig: ToolConfig) => {
}), }),
generate_storyboard: tool({ generate_storyboard: tool({
description: "生成分镜图片", description: "生成分镜图片",
inputSchema: z.object({ inputSchema: jsonSchema<{ ids: number[] }>({
ids: z.array(z.number()).describe("必须获取真实的分镜ID支持批量生成"), type: "object",
properties: {
ids: { type: "array", items: { type: "number" }, description: "必须获取真实的分镜ID支持批量生成" },
},
required: ["ids"],
additionalProperties: false,
}), }),
execute: async ({ ids }) => { execute: async ({ ids }) => {
const thinking = msg.thinking("正在生成分镜..."); const thinking = msg.thinking("正在生成分镜...");

View File

@ -1,6 +1,5 @@
import { Socket } from "socket.io"; import { Socket } from "socket.io";
import { tool } from "ai"; import { tool, jsonSchema } from "ai";
import { z } from "zod";
import u from "@/utils"; import u from "@/utils";
import Memory from "@/utils/agent/memory"; import Memory from "@/utils/agent/memory";
import useTools from "@/agents/scriptAgent/tools"; import useTools from "@/agents/scriptAgent/tools";
@ -133,8 +132,13 @@ function createSubAgent(parentCtx: AgentContext) {
return fullResponse; return fullResponse;
} }
const promptInput = z.object({ const promptInput = jsonSchema<{ prompt: string }>({
prompt: z.string().describe("交给子Agent的任务简约描述100字以内"), type: "object",
properties: {
prompt: { type: "string", description: "交给子Agent的任务简约描述100字以内" },
},
required: ["prompt"],
additionalProperties: false,
}); });
const run_sub_agent_storySkeleton = tool({ const run_sub_agent_storySkeleton = tool({

View File

@ -1,4 +1,4 @@
import { tool, Tool } from "ai"; import { tool, jsonSchema, Tool } from "ai";
import u from "@/utils"; import u from "@/utils";
import { z } from "zod"; import { z } from "zod";
import _ from "lodash"; import _ from "lodash";
@ -16,7 +16,7 @@ export const planData = z.object({
export type planData = z.infer<typeof planData>; export type planData = z.infer<typeof planData>;
const keySchema = z.enum(Object.keys(planData.shape) as [keyof planData, ...Array<keyof planData>]); const planDataKeys = Object.keys(planData.shape) as (keyof planData)[];
const planDataKeyLabels = Object.fromEntries( const planDataKeyLabels = Object.fromEntries(
Object.entries(planData.shape).map(([key, schema]) => [key, (schema as z.ZodTypeAny).description ?? key]), Object.entries(planData.shape).map(([key, schema]) => [key, (schema as z.ZodTypeAny).description ?? key]),
) as Record<keyof planData, string>; ) as Record<keyof planData, string>;
@ -33,8 +33,13 @@ export default (toolCpnfig: ToolConfig) => {
const tools: Record<string, Tool> = { const tools: Record<string, Tool> = {
get_novel_events: tool({ get_novel_events: tool({
description: "获取章节事件", description: "获取章节事件",
inputSchema: z.object({ inputSchema: jsonSchema<{ chapterIndexs: number[] }>({
chapterIndexs: z.array(z.number()).describe("章节的编号"), type: "object",
properties: {
chapterIndexs: { type: "array", items: { type: "number" }, description: "章节的编号" },
},
required: ["chapterIndexs"],
additionalProperties: false,
}), }),
execute: async ({ chapterIndexs }) => { execute: async ({ chapterIndexs }) => {
console.log("[tools] get_novel_events", 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") .select("id", "chapterIndex as index", "reel", "chapter", "chapterData", "event", "eventState")
.whereIn("chapterIndex", chapterIndexs); .whereIn("chapterIndex", chapterIndexs);
thinking.appendText("正在查询章节编号: " + chapterIndexs.join(",")); 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.appendText("查询结果:\n" + eventString);
thinking.updateTitle("查询章节事件完成"); thinking.updateTitle("查询章节事件完成");
thinking.complete(); thinking.complete();
@ -54,8 +59,13 @@ export default (toolCpnfig: ToolConfig) => {
}), }),
get_planData: tool({ get_planData: tool({
description: "获取工作区数据", description: "获取工作区数据",
inputSchema: z.object({ inputSchema: jsonSchema<{ key: keyof planData }>({
key: keySchema.describe("数据key"), type: "object",
properties: {
key: { type: "string", enum: planDataKeys as string[], description: "数据key" },
},
required: ["key"],
additionalProperties: false,
}), }),
execute: async ({ key }) => { execute: async ({ key }) => {
console.log("[tools] get_planData", key); console.log("[tools] get_planData", key);
@ -69,8 +79,13 @@ export default (toolCpnfig: ToolConfig) => {
}), }),
get_novel_text: tool({ get_novel_text: tool({
description: "获取小说章节原始文本内容", description: "获取小说章节原始文本内容",
inputSchema: z.object({ inputSchema: jsonSchema<{ chapterIndex: string }>({
chapterIndex: z.string().describe("章节编号"), type: "object",
properties: {
chapterIndex: { type: "string", description: "章节编号" },
},
required: ["chapterIndex"],
additionalProperties: false,
}), }),
execute: async ({ chapterIndex }) => { execute: async ({ chapterIndex }) => {
console.log("[tools] get_novel_text", "[tools] get_novel_text", chapterIndex); console.log("[tools] get_novel_text", "[tools] get_novel_text", chapterIndex);
@ -85,8 +100,13 @@ export default (toolCpnfig: ToolConfig) => {
}), }),
get_script_content: tool({ get_script_content: tool({
description: "获取剧本本内容", description: "获取剧本本内容",
inputSchema: z.object({ inputSchema: jsonSchema<{ ids: string[] }>({
ids: z.array(z.string()).describe("脚本id"), type: "object",
properties: {
ids: { type: "array", items: { type: "string" }, description: "脚本id" },
},
required: ["ids"],
additionalProperties: false,
}), }),
execute: async ({ ids }) => { execute: async ({ ids }) => {
console.log("[tools] get_script_content", "[tools] get_script_content", ids); console.log("[tools] get_script_content", "[tools] get_script_content", ids);

View File

@ -3,7 +3,7 @@ import u from "@/utils";
import { z } from "zod"; import { z } from "zod";
import { success } from "@/lib/responseFormat"; import { success } from "@/lib/responseFormat";
import { validateFields } from "@/middleware/middleware"; import { validateFields } from "@/middleware/middleware";
import { tool } from "ai"; import { tool, jsonSchema } from "ai";
const router = express.Router(); const router = express.Router();
// 获取资产 // 获取资产
@ -30,11 +30,25 @@ export default router.post(
try { try {
const resultTool = tool({ const resultTool = tool({
description: "返回结果时必须调用这个工具", description: "返回结果时必须调用这个工具",
inputSchema: z.object({ inputSchema: jsonSchema<{ result: { id: number; audioIds: number[] }[] }>({
result: z.array(z.object({ type: "object",
id: z.number(), properties: {
audioIds: z.array(z.number()).describe("适配的音频id 无适配内容可以为 空数组") result: {
})).describe("适配的音色列表id为资产idaudioIds为适配的音频id 无适配内容可以为 空数组") type: "array",
description: "适配的音色列表id为资产idaudioIds为适配的音频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 }) => { execute: async ({ result }) => {
console.log("[tools] extractAssets result", result); console.log("[tools] extractAssets result", result);

View File

@ -4,7 +4,7 @@ import { z } from "zod";
import { error, success } from "@/lib/responseFormat"; import { error, success } from "@/lib/responseFormat";
import { validateFields } from "@/middleware/middleware"; import { validateFields } from "@/middleware/middleware";
import { useSkill } from "@/utils/agent/skillsTools"; import { useSkill } from "@/utils/agent/skillsTools";
import { tool } from "ai"; import { tool, jsonSchema } from "ai";
import { o_script } from "@/types/database"; import { o_script } from "@/types/database";
const router = express.Router(); const router = express.Router();
@ -188,13 +188,40 @@ export default router.post(
try { try {
const resultTool = tool({ const resultTool = tool({
description: "返回结果时必须调用这个工具", description: "返回结果时必须调用这个工具",
inputSchema: z.object({ inputSchema: jsonSchema<{ newAssets: NewAsset[]; existingAssetRefs: ExistingAssetRef[] }>({
newAssets: z type: "object",
.array(NewAssetSchema) properties: {
.describe("新发现的资产列表(不在已有资产列表中的),需要完整的 prompt、name、desc、type 和使用该资产的 scriptIds"), newAssets: {
existingAssetRefs: z type: "array",
.array(ExistingAssetRefSchema) description: "新发现的资产列表(不在已有资产列表中的),需要完整的 prompt、name、desc、type 和使用该资产的 scriptIds",
.describe("已有资产的引用列表(在已有资产列表中已存在的),只需给出资产名称和使用该资产的 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 }) => { execute: async ({ newAssets, existingAssetRefs }) => {

View File

@ -3,7 +3,7 @@ import { success, error } from "@/lib/responseFormat";
import { validateFields } from "@/middleware/middleware"; import { validateFields } from "@/middleware/middleware";
import u from "@/utils"; import u from "@/utils";
import { z } from "zod"; import { z } from "zod";
import { tool } from "ai"; import { tool, jsonSchema } from "ai";
const router = express.Router(); const router = express.Router();
// 检查语言模型 // 检查语言模型
@ -57,8 +57,13 @@ export default router.post(
const getWeatherTool = tool({ const getWeatherTool = tool({
description: "Get the weather in a location", description: "Get the weather in a location",
inputSchema: z.object({ inputSchema: jsonSchema<{ location: string }>({
location: z.string().describe("The location to get the weather for"), type: "object",
properties: {
location: { type: "string", description: "The location to get the weather for" },
},
required: ["location"],
additionalProperties: false,
}), }),
execute: async ({ location }) => { execute: async ({ location }) => {
return { return {

View File

@ -2,8 +2,7 @@ import u from "@/utils";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { getEmbedding, cosineSimilarity } from "./embedding"; import { getEmbedding, cosineSimilarity } from "./embedding";
import type { memories as MemoryRow } from "@/types/database"; import type { memories as MemoryRow } from "@/types/database";
import { tool } from "ai"; import { tool, jsonSchema } from "ai";
import { z } from "zod";
// ── 可调配置默认值 ── // ── 可调配置默认值 ──
const DEFAULTS: { const DEFAULTS: {
@ -201,8 +200,13 @@ class Memory {
return { return {
deepRetrieve: tool({ deepRetrieve: tool({
description: "深度检索记忆:当你需要回忆与某个关键词相关的详细历史信息时使用此工具", description: "深度检索记忆:当你需要回忆与某个关键词相关的详细历史信息时使用此工具",
inputSchema: z.object({ inputSchema: jsonSchema<{ keyword: string }>({
keyword: z.string().describe("要检索的关键词"), type: "object",
properties: {
keyword: { type: "string", description: "要检索的关键词" },
},
required: ["keyword"],
additionalProperties: false,
}), }),
execute: async ({ keyword }) => { execute: async ({ keyword }) => {
const results = await this.deepRetrieve(keyword); const results = await this.deepRetrieve(keyword);

View File

@ -1,5 +1,4 @@
import { tool } from "ai"; import { tool, jsonSchema } from "ai";
import { z } from "zod";
import path from "path"; import path from "path";
import isPathInside from "is-path-inside"; import isPathInside from "is-path-inside";
import getPath from "@/utils/getPath"; import getPath from "@/utils/getPath";
@ -185,8 +184,13 @@ export function createSkillTools(skills: { name: string; description: string }[]
return { return {
activate_skill: tool({ activate_skill: tool({
description: `激活一个技能,加载其完整指令和捆绑资源列表到上下文。可用技能:${skillNames.join(", ")}`, description: `激活一个技能,加载其完整指令和捆绑资源列表到上下文。可用技能:${skillNames.join(", ")}`,
inputSchema: z.object({ inputSchema: jsonSchema<{ name: string }>({
name: z.enum(skillNames as [string, ...string[]]).describe("要激活的技能名称"), type: "object",
properties: {
name: { type: "string", enum: skillNames, description: "要激活的技能名称" },
},
required: ["name"],
additionalProperties: false,
}), }),
execute: async ({ name }) => { execute: async ({ name }) => {
if (activated.has(name)) { if (activated.has(name)) {
@ -222,8 +226,13 @@ export function createSkillTools(skills: { name: string; description: string }[]
}), }),
read_skill_file: tool({ read_skill_file: tool({
description: "读取已激活技能目录下的资源文件。传入 activate_skill 返回的 skill_resources 中的文件路径。", description: "读取已激活技能目录下的资源文件。传入 activate_skill 返回的 skill_resources 中的文件路径。",
inputSchema: z.object({ inputSchema: jsonSchema<{ filePath: string }>({
filePath: z.string().describe("资源文件的相对路径,来自 activate_skill 返回的 skill_resources"), type: "object",
properties: {
filePath: { type: "string", description: "资源文件的相对路径,来自 activate_skill 返回的 skill_resources" },
},
required: ["filePath"],
additionalProperties: false,
}), }),
execute: async ({ filePath }) => { execute: async ({ filePath }) => {
const normalizedInputPath = toUnixPath(filePath).trim(); const normalizedInputPath = toUnixPath(filePath).trim();