video-flow-toon/src/utils/agent/skillsTools.ts
gog5-ops 8dbcaadfaf 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>
2026-04-26 00:20:59 +00:00

284 lines
10 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { tool, jsonSchema } from "ai";
import path from "path";
import isPathInside from "is-path-inside";
import getPath from "@/utils/getPath";
import * as fs from "fs";
import fg from "fast-glob";
type SkillAttribution =
//剧本Agent
| "script_agent_decision" //决策
| "script_execution_skeleton" //故事骨架
| "script_execution_adaptation" //改变策略
| "script_execution_script" //剧本生成
| "script_agent_supervision" //审核
//生产Agent
| "production_agent_decision"
| "production_agent_execution"
| "production_agent_supervision";
interface SkillInput {
mainSkill: SkillAttribution[];
workspace?: string[];
attachedSkills?: string[];
}
interface SkillPaths {
mainSkill: { path: string; name: string; description: string }[];
secondarySkills: string[];
tertiarySkills: string[];
}
function toUnixPath(filePath: string): string {
return filePath.replace(/\\/g, "/");
}
function ensureNonEmptyBody(body: string, fallback: string): string {
const trimmed = body.trim();
return trimmed.length > 0 ? trimmed : fallback;
}
// ==================== 解析 SKILL.md ====================
export function parseFrontmatter(content: string): { name: string; description: string } {
const match = content.match(/^\uFEFF?---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/);
if (!match?.[1]) {
throw new Error(`技能文件缺少有效的 frontmatter确保以 --- 包裹并包含 name 和 description 字段。${content}`);
}
const result: Record<string, string> = {};
const lines = match[1].split(/\r?\n/);
for (let i = 0; i < lines.length; ) {
const line = lines[i];
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) {
i++;
continue;
}
const keyMatch = line.match(/^([A-Za-z0-9_-]+)\s*:\s*(.*)$/);
if (!keyMatch) {
i++;
continue;
}
const key = keyMatch[1].trim();
const rawValue = (keyMatch[2] ?? "").trim();
i++;
if (!key) continue;
if (/^[>|][+-]?[0-9]*$/.test(rawValue)) {
const isFolded = rawValue.startsWith(">");
const blockLines: string[] = [];
let blockIndent: number | null = null;
while (i < lines.length) {
const current = lines[i];
const currentTrimmed = current.trim();
if (currentTrimmed === "") {
if (blockIndent !== null) blockLines.push("");
i++;
continue;
}
const currentIndent = current.match(/^\s*/)?.[0].length ?? 0;
if (blockIndent === null) {
blockIndent = currentIndent;
}
if (currentIndent < blockIndent) break;
blockLines.push(current.slice(blockIndent));
i++;
}
result[key] = isFolded
? blockLines
.join("\n")
.replace(/\n{2,}/g, "\n\n")
.replace(/([^\n])\n([^\n])/g, "$1 $2")
.trim()
: blockLines.join("\n").trim();
continue;
}
const unquoted = rawValue.replace(/^(['"])([\s\S]*)\1$/, "$2");
result[key] = unquoted;
}
if (!result.name || !result.description) {
throw new Error(`技能文件缺少必要字段: name 或 description确保 frontmatter 包含这两个字段。${content}`);
}
return { name: result.name, description: result.description };
}
export async function useSkill(input: SkillInput) {
const { mainSkill, workspace = [], attachedSkills = [] } = input;
const rootDir = getPath("skills");
const normalizedRootDir = path.resolve(rootDir);
const mainSkills: { path: string; name: string; description: string }[] = [];
for (const skill of mainSkill) {
const skillPath = path.join(rootDir, skill + ".md");
if (!fs.existsSync(skillPath)) throw new Error(`主技能文件不存在: ${skillPath}`);
if (!isPathInside(skillPath, normalizedRootDir)) throw new Error(`技能名称无效:检测到路径穿越。${skillPath}`);
const content = await fs.promises.readFile(skillPath, "utf-8");
const parsed = parseFrontmatter(content);
mainSkills.push({ path: skillPath, ...parsed });
}
const resolveSafeSkillDir = (dir: string): string | null => {
const resolvedDir = path.resolve(normalizedRootDir, dir);
const isSafeDir = resolvedDir === normalizedRootDir || isPathInside(resolvedDir, normalizedRootDir);
return isSafeDir ? resolvedDir : null;
};
const getMdFiles = (dir: string, recursive = false): string[] => {
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const fullPath = path.join(dir, entry.name);
if (entry.isFile() && entry.name.endsWith(".md")) return [fullPath];
return entry.isDirectory() && recursive ? getMdFiles(fullPath, true) : [];
});
};
const collectMdFiles = (dirs: string[], recursive: boolean) =>
dirs.flatMap((dir) => {
const safeDir = resolveSafeSkillDir(dir);
if (!safeDir) return [];
return getMdFiles(safeDir, recursive).map((file) => toUnixPath(path.relative(normalizedRootDir, file)));
});
const skillPaths: SkillPaths = {
mainSkill: mainSkills,
secondarySkills: collectMdFiles(workspace, false),
tertiarySkills: collectMdFiles(attachedSkills, true),
};
return { prompt: buildSkillPrompt(mainSkills), tools: createSkillTools(mainSkills, skillPaths), skillPaths };
}
export function buildSkillPrompt(skills: { name: string; description: string }[]): string {
const skillEntries = skills
.map((s) => ` <skill>\n <name>${s.name}</name>\n <description>${s.description}</description>\n </skill>`)
.join("\n");
return `## Skills
以下技能提供了专业任务的专用指令。
当任务与某个技能的描述匹配时,调用 activate_skill 工具并传入技能名称来加载完整指令。
加载后遵循技能指令执行任务,需要时调用 read_skill_file 读取资源文件内容。
<available_skills>
${skillEntries}
</available_skills>`;
}
export function createSkillTools(skills: { name: string; description: string }[], skillPaths: SkillPaths, rootDir: string = getPath("skills")) {
const activated = new Set<string>(); // 已激活技能集合,防止重复加载
const skillsRootDir = path.resolve(rootDir);
const skillNames = skills.map((s) => s.name);
const skillMap = new Map(skillPaths.mainSkill.map((s) => [s.name, s]));
return {
activate_skill: tool({
description: `激活一个技能,加载其完整指令和捆绑资源列表到上下文。可用技能:${skillNames.join(", ")}`,
inputSchema: jsonSchema<{ name: string }>({
type: "object",
properties: {
name: { type: "string", enum: skillNames, description: "要激活的技能名称" },
},
required: ["name"],
additionalProperties: false,
}),
execute: async ({ name }) => {
if (activated.has(name)) {
console.log(`⚡[主技能] 技能 "${name}" 已激活,跳过重复注入`);
return { alreadyActive: true, message: `技能 "${name}" 已激活,无需重复加载` };
}
const matched = skillMap.get(name);
if (!matched) return { error: `未找到技能 "${name}"` };
let raw = "";
try {
raw = await fs.promises.readFile(matched.path, "utf-8");
console.log(`⚡[主技能] ✓ 已读取主技能文件: ${matched.path}${raw.length} 字符)`);
} catch (error) {
console.log(`⚡[主技能] ✗ 读取失败:未找到文件 "${matched.path}"`);
}
activated.add(name);
console.log(`⚡[主技能] ✓ 技能 "${name}" 已激活`);
const body = ensureNonEmptyBody(raw.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, ""), "该技能文件无正文内容。");
let content = "";
content = `<skill_content name="${name}">\n`;
content += body + "\n\n";
content += "使用 read_skill_file 工具读取资源文件。\n";
if (skillPaths.secondarySkills.length > 0) {
content += "\n<skill_resources>\n";
for (const path of skillPaths.secondarySkills) {
content += ` <file>${path}</file>\n`;
}
content += "</skill_resources>\n";
}
content += "</skill_content>";
return { content };
},
}),
read_skill_file: tool({
description: "读取已激活技能目录下的资源文件。传入 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();
if (!normalizedInputPath) {
console.log(`📖[技法文件] ✗ filePath 不能为空`);
return { error: "filePath 不能为空" };
}
const fullPath = path.resolve(path.join(skillsRootDir, normalizedInputPath));
if (!(fullPath === skillsRootDir || isPathInside(fullPath, skillsRootDir))) {
console.log(`📖[技法文件] ✗ 路径越界已拦截:"${filePath}" 超出技能目录范围`);
return { error: "Access denied: path is outside skill directory" };
}
let body = "";
try {
body = await fs.promises.readFile(fullPath, "utf-8");
console.log(`📖[技法文件] ✓ 已读取文件: ${filePath}${body.length} 字符)`);
} catch {
console.log(`📖[技法文件] ✗ 读取失败:未找到文件 "${filePath}"`);
return { error: `File not found: ${filePath}` };
}
const safeBody = ensureNonEmptyBody(body, "该资源文件为空。");
let content = "";
content = `<skill_content>\n`;
content += safeBody + "\n\n";
content += "可以使用 read_skill_file 工具读取资源文件。\n";
if (skillPaths.tertiarySkills.length > 0) {
content += "\n<skill_resources>\n";
for (const path of skillPaths.tertiarySkills) {
content += ` <file>${path}</file>\n`;
}
content += "</skill_resources>\n";
}
content += "</skill_content>";
return { content };
},
}),
};
}
export async function scanSkills(folderPath: string) {
const unixPath = toUnixPath(folderPath);
const entries = await fg(unixPath, {
onlyFiles: true,
absolute: true,
});
return entries;
}