fix(plugins): 附件 id 归一 —— 修 opencode「做完全部活却发不出附件」
# 现象(全功能演练抓到,根因来自 opencode 自己的内部记录)
opencode 把四个步骤全做完了(2× download_attachment、read 读到内容、
upload_attachment 成功),却在最后一步卡死:`send_mail` 连续 **6 次**失败,
然后放弃整个任务,自述为「attachment_ids 参数有框架级序列化 bug」。
真实形状(从 opencode 的 part 表里取出的原始输入):
input.attachment_ids = "[\"10e73e9f-c2a9-4226-bdb5-34ef1b340eb8\"]" ← 字符串
error: 字段 "attachment_ids" 类型不对:期望 string 数组,收到 string
模型把数组写成了 **JSON 字符串**,桥原样转发,服务端的严格解码器按契约拒收。
# 修在哪一层
**不在服务端放宽。** 那个「严格」是刻意的,挡的是字段名拼错、结构写错这类真
错误 —— 松开之后真 bug 会被静默接受(同一封邮件少几个附件,HTTP 仍是 200)。
**在桥这一层收。** 桥是适配器:模型侧的形状天生不可靠,而适配器的职责就是把
不可靠的输入归一成契约要求的形状。对模型宽容、对服务端严格 —— 这与
homeagent 那个 Go 插件里的 `stringList` 是同一个判断(那边注释写着「也接受
单个字符串……拒绝它只会换来一次重试,而意图毫无歧义」)。
接受的形状:数组 / JSON 数组字符串 / 单个 id / 逗号或空白分隔 / 混进 null
与数字时丢掉坏的保留好的。空串与 null 一并丢掉,与服务端
`parseAttachmentIDs` 保持一致。
# 三桥同源
新增共用模块 `lib/attachment-ids.js` + 同名测试,已加入
`deploy/check-shared-libs.sh` 的两个清单(实现与测试都必须逐字节相同 ——
只同步实现不同步测试,等于允许一侧偷偷放宽约定)。
三份 md5 一致,检查脚本通过。
# 测试
`test/attachment-ids.test.mjs` 17 条,三桥各一份。含**反向对照**:把 JSON 字符串
分支去掉后必须变红(实测 3 条失败)—— 否则这条判据就是空转,事故会复发。
全量:pi 401 / dsh 361 / opencode 312,0 失败。
This commit is contained in:
72
plugins/pi-mail-bridge/lib/attachment-ids.js
Normal file
72
plugins/pi-mail-bridge/lib/attachment-ids.js
Normal file
@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 把工具参数里的附件 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];
|
||||
}
|
||||
Reference in New Issue
Block a user