/** * 启动补拉:把插件离线期间到的邮件变成与 SSE 事件同形的投递任务。 * * 为什么需要它:**SSE 只推连上之后的事件**。插件重启前发来的邮件不会再推一次, * 心跳响应的 `pending_mails` 是唯一线索。不补拉的后果是那封邮件永远躺在 * 收件箱里,而发件人以为 Agent 收到了 —— 这比明确的失败更难排查。 * * 两个平台共用,必须逐字节相同(deploy/check-shared-libs.sh 校验)。 */ /** * 一次补拉最多处理几封。 * * 上限存在的理由:每封都要起一轮模型。攒了 80 封的时候一次性全放出去, * 等于对上游打 80 个并发请求,且最后那几封要等前面全部跑完。 * 超出的部分留在收件箱里,下次重启或人工触发时再处理。 */ export const MAX_CATCHUP = 5; /** * 把收件箱里的一封邮件转成 SSE `new_mail` 那个形状。 * * 补拉与 SSE 走同一条投递路径(deliverMail),因此形状必须一致 —— * 两条路径各写一遍投递逻辑的话,某一条上的修复会漏掉另一条。 * * @param {any} mail `/mail/inbox` 返回的一行 * @returns {{mail_id: string, session_id: string, from_name: string, * subject: string, mail_type: string, role: string, * to_workspace: string, catchup: true}} */ export function mailToEvent(mail) { return { mail_id: mail?.mail_id || '', session_id: mail?.session_id || '', from_name: mail?.from_name || '', subject: mail?.subject || '', mail_type: mail?.mail_type || 'normal', role: 'to', to_workspace: mail?.to_workspace || '', // 标记来源,投递侧可据此决定是否在提示词里说明「这是积压的邮件」 catchup: true, }; } /** * 从收件箱挑出该补投的邮件。 * * @param {any[]} mails `/mail/inbox?status=unread` 的结果 * @param {Set} seen 已经通过 SSE 投过的 mail_id(避免重复投递) * @param {number} [max] 上限,默认 MAX_CATCHUP * @returns {any[]} 与 SSE 事件同形的投递任务,按时间正序(老的先处理) */ export function selectCatchup(mails, seen, max = MAX_CATCHUP) { if (!Array.isArray(mails) || mails.length === 0) return []; const picked = []; for (const m of mails) { const id = m?.mail_id; if (!id) continue; // 心跳与 SSE 建连之间有个窗口:那期间到的邮件既在 pending_mails 里、 // 也会被 SSE 推一次。不去重就会投两遍,模型回两封信。 if (seen && seen.has(id)) continue; // permission 类邮件不补投:它是给人看的询问,Agent 侧没有可恢复的上下文 // (原来的工具调用早随进程一起没了),投过去只会让模型困惑。 if (m?.mail_type && m.mail_type !== 'normal') continue; picked.push(m); } // 收件箱按时间倒序返回,补投要按正序 —— 先来的先处理, // 否则同一会话里的多封邮件会被倒着塞进去,上下文顺序是乱的。 picked.reverse(); return picked.slice(0, Math.max(0, max)).map(mailToEvent); }