/** * 把工具参数里的附件 id 列表归一成 `string[]`。 * * # 为什么需要它(生产实测) * * opencode 上一轮把四个步骤全做完了 —— 下载两个附件、读出内容、上传回传文件 —— * 却在最后一步卡住:`send_mail` 连续 **6 次**失败,模型自己总结为 * 「attachment_ids 参数有框架级序列化 bug」,然后放弃了整个任务。 * * 真实原因不是框架 bug,而是模型把数组写成了 **JSON 字符串**: * * attachment_ids = "[\"10e73e9f-c2a9-4226-bdb5-34ef1b340eb8\"]" * * 桥把这个字符串原样转发给服务端,服务端的严格解码器按契约拒收 * (`字段 "attachment_ids" 类型不对:期望 string 数组,收到 string`)。 * * # 修在哪一层 * * **不在服务端放宽。** 服务端那个"严格"是刻意的,它挡的是字段名拼错、结构写错 * 这类真错误 —— 松开之后真 bug 会被静默接受(同一封邮件少几个附件,HTTP 仍是 * 200)。 * * **在桥这一层收。** 桥是适配器:模型侧的形状天生不可靠(它按自然语言直觉填 * 参数),而适配器的职责就是把不可靠的输入归一成契约要求的形状。对模型宽容、 * 对服务端严格,这与 homeagent 那个 Go 插件里的 `stringList` 是同一个判断 * (那边的注释写着:「也接受单个字符串……拒绝它只会换来一次重试,而意图毫无 * 歧义」)。 * * # 接受的形状 * * - `["a", "b"]` 数组 * - `"[\"a\", \"b\"]"` JSON 数组字符串 ← **本次事故的形状** * - `"a"` 单个 id * - `"a, b"` / `"a b"` 逗号或空白分隔 * - `[null, "a", 3]` 混进杂质:丢掉坏的、留下好的 * * 逐项过滤而不是整体放弃:三个附件里有一个写坏,不该变成"一个都不发"。 * 空串与 null 一并丢掉 —— 服务端的 parseAttachmentIDs 也跳过空串, * 与它保持一致,免得插件放过去的东西换个形状在服务端再失败一次。 * * @param {unknown} value 工具参数里的原始值 * @returns {string[]} 归一后的 id 列表(永不为 null) */ export function normalizeAttachmentIDs(value) { if (value === null || value === undefined) return []; if (Array.isArray(value)) return value.flatMap(normalizeAttachmentIDs); if (typeof value !== 'string') return []; const raw = value.trim(); if (raw === '') return []; // JSON 数组字符串:本次事故的形状。解析失败不算错 —— 它可能就是普通 id。 if (raw.startsWith('[')) { try { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) return parsed.flatMap(normalizeAttachmentIDs); } catch { // 落到下面的分隔符分支 } } // 逗号/空白分隔:模型偶尔写成 "a, b"。 // 只在确实含分隔符时切分,避免把单个 id 切坏 —— uuid 里没有这些字符。 if (/[,\s]/.test(raw)) { return raw .split(/[,\s]+/) .map(s => s.trim()) .filter(s => s !== ''); } return [raw]; }