/** * 邮件工具(T-1..T-6)—— 注册给 pi 里的模型。 * * pi 的工具定义用 TypeBox schema,这里直接写等价的 JSON Schema 字面量: * TypeBox 的 `Type.Object({...})` 产出的就是这个形状,而桥是 .mjs(无编译步骤), * 少一个运行时依赖。 * * `execute(toolCallId, params, signal, onUpdate, ctx)` 的 ctx 是 ExtensionContext, * 由此可以拿到 `ctx.sessionManager.getSessionId()` —— 这就是 C-6 要求的 * 「工具能拿到当前会话 id」,自动转发去重(B-5.3)靠它把发信记到正确的会话上。 */ import { readFile, writeFile, mkdir, stat } from 'node:fs/promises'; import { basename, dirname } from 'node:path'; import { renderInbox, idsToMarkRead, formatSize, DEFAULT_INBOX_STATUS, DEFAULT_INBOX_LIMIT, } from '../lib/inbox-format.js'; import { renderNameSuggestions, renderPathSuggestions, renderSessionSuggestions, renderParticipants, renderContacts, renderThread, } from '../lib/discovery.js'; import { noteExplicitSend } from '../lib/relay-dedup.js'; import { appendRenameProposal, renameProposalNote } from '../lib/rename-proposal.js'; import { saveLocalKey, generateLocalKey, saveConfig, KEY_FILE } from './gateway.mjs'; const text = (s) => ({ content: [{ type: 'text', text: s }] }); /** * @param {object} deps * @param {import('./gateway.mjs').GatewayClient} deps.client * @param {(msg: string) => void} deps.log * @param {string} [deps.agentName] 自己的 Agent 名。收件箱渲染靠它判定 * 「我是收件人还是抄送方」并给出可投递地址。 * @param {() => void} [deps.onReconnect] connect_to_server 换了坐标后调用, * 由入口重连 SSE。不给则只改客户端字段(下次重连时生效)。 */ /** * 工具参数 schema 里**不要写 `additionalProperties: false`**。 * * pi 的模型侧会在 arguments 里塞一个 `_ref`(thinking 上下文的引用句柄), * 那不是模型编的参数而是运行时注入的。声明 additionalProperties: false * 会让 typebox(pi-ai 的 validateToolArguments)判它非法: * * Validation failed for tool "read_inbox": * - root: must not have additional properties * * 后果是**活锁而不是报错**:模型收到校验失败 → 重试 → 又被拒。生产实测一条 * 会话连撞 13 次,其间它想跑 bash 上报目录,于是反复申请授权 —— 人看到的 * 现象是「pi 一直有个对话在跑一直在要授权」。而每次重试的 toolCallId 都是新的, * 幂等键各不相同,所以每一次都生成一封新的权限邮件。 * * opencode / dsh / homeagent 三个平台都没写这一行,只有这里写了 —— 它不是 * 「更严格更好」,而是与 pi 的参数传递机制直接冲突。 */ export function createMailTools({ client, log, agentName = '', onReconnect }) { const sendMail = { name: 'send_mail', label: 'SendMail', description: '发送邮件。三维地址 name@path.session:省略 session 投递到默认会话,' + '.new 强制新建,.具体别名 必须已存在。回复来信请传 reply_to。', parameters: { type: 'object', properties: { to: { type: 'string', description: '收件人三维地址,如 admin@/home/program/x' }, subject: { type: 'string', description: '邮件主题' }, body: { type: 'string', description: '邮件正文(Markdown)' }, cc: { type: 'string', description: '抄送,逗号分隔多个三维地址' }, reply_to: { type: 'string', description: '回复某封邮件时传其 mail_id' }, session_alias: { type: 'string', description: '给新会话命名(仅 .new 时生效)' }, attachment_ids: { type: 'array', items: { type: 'string' }, description: '附件 ID 列表(先用 upload_attachment 取得)', }, propose_alias: { type: 'string', description: '建议把当前会话改名成这个别名(例如摸清问题后从「排查登录问题」改成 ' + 'fix-session-cookie-leak)。这只是建议:别名是人的寻址入口,实际改名由用户' + '在界面上确认。不可含 . / @ 空白,不可为 new', }, propose_reason: { type: 'string', description: '改名理由,一句话,展示给用户看' }, }, required: ['to', 'subject', 'body'], }, async execute(_id, params, _signal, _onUpdate, ctx) { // 改名提议以 HTML 注释形式附在正文末尾,由网关解析后剥离。 // 拼接收在 lib/rename-proposal.js(三平台共用):标记格式是服务端正则的 // 镜像,各写一遍的话少个空格就静默失效 —— 邮件照常发出,提议凭空消失。 const { body, proposed } = appendRenameProposal( params.body, params.propose_alias, params.propose_reason); const result = await client.post('/mail/send', { to: params.to, subject: params.subject, body, cc: params.cc || '', reply_to: params.reply_to || '', session_alias: params.session_alias || '', attachment_ids: params.attachment_ids || [], from_session_id: ctx?.sessionManager?.getSessionId?.() || '', // 这里**不带 relay**(N-5):模型的自主发信要计配额, // 免配额通道只给插件代劳的转发(总结、权限询问、故障报告)。 }); // 记下「模型这一轮亲手发过信」,供 B-5.3 让位判定。 // 会话 id 从 ctx 取:工具不知道自己被哪条会话调用,就没法正确归属。 noteExplicitSend(ctx?.sessionManager?.getSessionId?.(), params.to, params.reply_to); const budget = typeof result.budget_remaining === 'number' ? ` 本任务剩余 ${result.budget_remaining}/${result.budget_max} 个来回。` : ''; // 别名取服务端回的 rename_proposed(它跑过 normalizeAlias), // 回显本地值会让模型记住一个不存在的名字,之后拿它寻址就 404 const note = renameProposalNote(result.rename_proposed, params.propose_alias, proposed); return text( `邮件已发送(ID: ${result.mail_id})${budget}` + (note ? `\n${note}` : '')); }, }; const readInbox = { name: 'read_inbox', label: 'ReadInbox', description: '查阅收件箱中的邮件。收到新邮件通知后应立即调用此工具。' + '每封含 mail_id、发件人、主题、正文与附件清单(带 attachment_id)。', parameters: { type: 'object', properties: { status: { type: 'string', description: '过滤条件 unread|all,默认 unread' }, limit: { type: 'number', description: '返回数量,默认 5' }, }, }, async execute(_id, params) { const status = params.status || DEFAULT_INBOX_STATUS; const { mails } = await client.get( `/mail/inbox?status=${encodeURIComponent(status)}&limit=${params.limit || DEFAULT_INBOX_LIMIT}`, ); // 渲染与已读策略走共用模块:与另两个平台必须一致, // 每条规则对应过一次真实的错误行为(见 lib/inbox-format.js)。 // // 传 agentName 才能判定身份并给出可投递地址 —— 不传的话模型只能 // 从抄送行里抄一个 `.new`,而那是一次性的,回过去只会再建一条平行会话。 const listed = renderInbox(mails, 200, agentName); const ids = idsToMarkRead(params.status, mails); if (ids.length) { // 标记失败不该让 read_inbox 失败:正文已经取到了, // 代价只是下次重复看到,比丢掉这次读取轻。 client.post('/mail/read', { mail_ids: ids }).catch((e) => log(`[pi-mail-bridge] 标记已读失败: ${e?.message || e}`)); } return text(listed); }, }; const forwardMail = { name: 'forward_mail', label: 'ForwardMail', description: '转发一封邮件给新的收件人(引用原文)。与回复不同:回复落回原会话,' + '转发按目标地址另行定位会话。只能转发自己参与过的邮件。', parameters: { type: 'object', properties: { mail_id: { type: 'string', description: '要转发的邮件 ID(从 read_inbox 获得)' }, to: { type: 'string', description: '新收件人的三维地址' }, comment: { type: 'string', description: '转发说明,置于引用原文之前' }, cc: { type: 'string', description: '抄送,逗号分隔多个三维地址' }, subject: { type: 'string', description: '自定义主题;留空则自动加 Fwd: 前缀' }, session_alias: { type: 'string', description: '仅在目标地址以 .new 结尾时生效:给新会话命名' }, }, required: ['mail_id', 'to'], }, async execute(_id, params, _signal, _onUpdate, ctx) { // 路径带 mail_id(POST /mail/{id}/forward),不是请求体里的字段 const result = await client.post(`/mail/${params.mail_id}/forward`, { to: params.to, comment: params.comment || '', cc: params.cc || '', subject: params.subject || '', session_alias: params.session_alias || '', }); noteExplicitSend(ctx?.sessionManager?.getSessionId?.(), params.to, ''); return text(`已转发。新 Mail ID: ${result.mail_id},Session: ${result.session_id}`); }, }; const uploadAttachment = { name: 'upload_attachment', label: 'UploadAttachment', description: '上传本地文件作为邮件附件,返回 attachment_id。' + '拿到 id 后必须在 send_mail 的 attachment_ids 里带上,附件才会随邮件发出。' + '未随邮件发出的附件 24 小时后自动清理。', parameters: { type: 'object', properties: { file_path: { type: 'string', description: '要上传的本地文件绝对路径' }, filename: { type: 'string', description: '自定义展示文件名,默认取路径的最后一段' }, }, required: ['file_path'], }, async execute(_id, params) { // 先 stat 再读:目录和不存在的路径都要给出能行动的错误。 // 直接 readFile 的话,目录会抛 EISDIR —— 模型看到那个 errno // 只会重试同一个路径,而不是去改参数。 let st; try { st = await stat(params.file_path); } catch { return text(`文件不存在或不可读: ${params.file_path}`); } if (!st.isFile()) return text(`不是普通文件: ${params.file_path}`); // 附件上限 25MB,一次性读入内存可接受。上限放宽的话这里要改成流式 multipart。 const buf = await readFile(params.file_path); const name = params.filename || basename(params.file_path) || 'file'; const a = await client.uploadFile(buf, name); return text( `已上传 ${a.filename}(${formatSize(a.size_bytes)})。attachment_id: ${a.attachment_id}\n` + `在 send_mail 的 attachment_ids 里带上这个 id 才会随邮件发出。`, ); }, }; const downloadAttachment = { name: 'download_attachment', label: 'DownloadAttachment', description: '下载邮件附件到本地文件。attachment_id 从 read_inbox 的附件清单里取。', parameters: { type: 'object', properties: { attachment_id: { type: 'string', description: '附件 ID(read_inbox 的清单里给出)' }, save_path: { type: 'string', description: '保存到的本地绝对路径' }, }, required: ['attachment_id', 'save_path'], }, async execute(_id, params) { const buf = await client.downloadFile(params.attachment_id); // 父目录不存在时先建:模型经常写 ./downloads/x.pdf 这类还不存在的路径, // 不建的话 writeFile 抛 ENOENT,而那个错误看起来像「附件不存在」。 await mkdir(dirname(params.save_path), { recursive: true }); await writeFile(params.save_path, buf); return text(`已保存到 ${params.save_path}(${formatSize(buf.length)})`); }, }; // ─── 寻址发现工具(读 Agent 侧只读端点)─── // // 在这一组之前,send_mail 的 to 是个只能靠记忆拼写的自由文本字段, // 而拼错不报错:生产上另一个平台猜了 `opencode@/home`,投递成功, // 但那不是 opencode 的工作目录,静默变成了新会话的 workspace。 // // 渲染逻辑在 lib/discovery.js(三平台共用)。 const suggestAddress = { name: 'suggest_address', label: 'SuggestAddress', description: '查询可用的收件人地址,用于精准发信。不带参数给候选收件人名;带 name 给它可用的' + '工作目录;name+path 都带则给该目录下可续谈的会话与现成地址。' + '**发信前应先用它确认地址**,不要凭记忆拼写 —— 拼错不会报错,只会投到别的会话。', parameters: { type: 'object', properties: { name: { type: 'string', description: '收件人名;留空则列出所有候选收件人' }, path: { type: 'string', description: '工作目录;与 name 同时给出才列会话' }, }, }, async execute(_id, params) { const name = String(params.name || '').trim(); const path = String(params.path || '').trim(); const qs = new URLSearchParams(); if (name) qs.set('name', name); if (path) qs.set('path', path); const data = await client.get(`/agent/contacts/suggest?${qs.toString()}`); // 按服务端回的 kind 分派而不是按本地参数:省略与传空串在服务端 // 是同一个意思,但「哪一段该渲染成什么」只有服务端知道。 switch (data?.kind) { case 'name': return text(renderNameSuggestions(data.suggestions)); case 'path': return text(renderPathSuggestions(data.suggestions, name)); default: return text(renderSessionSuggestions(data, name, path)); } }, }; const listContacts = { name: 'list_contacts', label: 'ListContacts', description: '列出自己参与过的全部会话及各自的可投递地址、未读数、剩余往返预算。' + '用于回答「我还有什么没处理」与「上次跟某人聊的那条线索地址是什么」。', parameters: { type: 'object', properties: { limit: { type: 'number', description: '最多列出多少条,默认 20' }, }, }, async execute(_id, params) { const data = await client.get('/agent/contacts'); return text(renderContacts(data, params.limit || 20)); }, }; const sessionParticipants = { name: 'session_participants', label: 'SessionParticipants', description: '列出某条会话的全部参与方(发件人/收件人/抄送方)及各自的可投递地址,' + '并标出谁还没回应。**要回给抄收方或向第三方转达时先用它拿地址**。', parameters: { type: 'object', properties: { session_id: { type: 'string', description: '会话 ID' }, }, required: ['session_id'], }, async execute(_id, params) { const data = await client.get(`/agent/sessions/${params.session_id}/participants`); return text(renderParticipants(data)); }, }; const readThread = { name: 'read_thread', label: 'ReadThread', description: '查看一封邮件所在线索的完整往来(谁回了谁、谁还没回)。多方抄送协作时' + '用它确认别人已经说了什么,避免重复提问或重复汇报。', parameters: { type: 'object', properties: { mail_id: { type: 'string', description: '线索中任一封邮件的 ID' }, offset: { type: 'number', description: '分页偏移,续取时传上次返回的 next_offset' }, }, required: ['mail_id'], }, async execute(_id, params) { const qs = params.offset ? `?offset=${params.offset}` : ''; const data = await client.get(`/agent/mail/${params.mail_id}/thread${qs}`); return text(renderThread(data, agentName)); }, }; const readMail = { name: 'read_mail', label: 'ReadMail', description: '读一封邮件的完整内容,含收件人、抄送清单、附件与每个参与方的可投递地址。' + '收件箱只给摘要;要回给抄收方就得先看清这封信发给了谁。', parameters: { type: 'object', properties: { mail_id: { type: 'string', description: '邮件 ID' }, }, required: ['mail_id'], }, async execute(_id, params) { const data = await client.get(`/agent/mail/${params.mail_id}`); const m = data?.mail || {}; const lines = [ `发件人: ${m.from_name || '?'}`, `收件人: ${m.to_name || '?'}${m.to_workspace ? '@' + m.to_workspace : ''}`, `主题: ${m.subject || '(无主题)'}`, `会话: #${data.session_alias || '未命名'}(session_id: ${m.session_id || '?'})`, ]; if (Array.isArray(m.cc_list) && m.cc_list.length) { lines.push(`抄送: ${m.cc_list.map(c => c?.raw || c?.name).join('、')}`); } if (Array.isArray(m.attachments) && m.attachments.length) { lines.push(`附件: ${m.attachments .map(a => `${a.filename}(${formatSize(a.size_bytes)}, id=${a.attachment_id})`) .join('、')}`); } lines.push('', m.body || '(空正文)', ''); if (Array.isArray(data.participants) && data.participants.length) { lines.push('可投递地址: ' + data.participants .filter(p => p.address && p.name !== agentName) .map(p => `${p.address}(${p.role})`) .join('、')); } if (data.reply_address) { lines.push(`回信给发件人用 ${data.reply_address},或传 reply_to=${m.mail_id}。`); } return text(lines.join('\n')); }, }; // connect_to_server —— 连接自愈。 // // 之前只有 opencode 侧有。后果是:Gateway 换了地址、或密钥需要重新登记时, // opencode 里的模型能自己修好,其他平台只能干等环境变量被人改 —— // 同一类能力在不同平台上时有时无,等于让人记住哪个平台能自己修。 // // 失败时**把需要登记的密钥全文打出来**:密钥未登记是最常见的失败, // 不给值的话要多走一轮「密钥无效 → 去哪拿 → 让管理员登记」。 const connectToServer = { name: 'connect_to_server', label: 'ConnectToServer', description: '连接到 AgentMail Gateway:登记本机密钥并完成注册。首次安装或换了 Gateway 地址时调用。' + '密钥若未在后台登记过,此处会返回需要登记的密钥全文。', parameters: { type: 'object', properties: { gateway_url: { type: 'string', description: 'Gateway 地址;省略则用当前配置' }, key_token: { type: 'string', description: '管理员签发的 Agent 密钥;省略则用本地密钥(不存在时自动生成)' }, }, }, async execute(_id, params) { let key = client.agentKey; if (params.key_token) { key = String(params.key_token).trim(); // 管理员给的密钥落盘,重启后仍然可用 saveLocalKey(key); } else if (!key) { key = generateLocalKey(log); } const url = String(params.gateway_url || client.baseURL).replace(/\/+$/, ''); const res = await fetch(`${url}/api/v1/agent/register`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` }, body: JSON.stringify({ name: client.agentName, workspaces: [], platform: 'pi' }), }); const data = await res.json().catch(() => ({})); if (!res.ok) { return text([ `连接失败(HTTP ${res.status}):${data?.error || '未知错误'}`, ``, `若提示密钥无效,请让管理员在 AgentMail 后台「Agent 密钥」中登记:`, key, ``, `密钥文件:${KEY_FILE}`, ].join('\n')); } // 成功。桥是守护进程,不能靠重启来应用新坐标 —— 模型调这个工具时 // 期望调完就能收信,所以要当场改客户端并重连 SSE。 // reconfigure 顺带清掉 lastEventID:那是旧 Gateway 缓冲里的序号。 const changed = client.reconfigure({ url, agentKey: key }); saveConfig({ gateway_url: url, agent_name: client.agentName, registered_at: new Date().toISOString() }); if (changed && onReconnect) { onReconnect(); log(`connect_to_server 换了坐标,SSE 已重连到 ${url}`); } return text( `已连接 ${url},注册为 ${data?.agent_name || client.agentName}。` + (changed ? '事件流已切到新地址。' : '')); }, }; // 故意**没有** request_permission(N-1 / T-7): // 权限询问由 tool_call 钩子接管 —— 模型可能忘了调,也可能在不需要时乱调, // 而真正被 pi 拦下的那一次才是事实。 return [ sendMail, readInbox, readMail, forwardMail, uploadAttachment, downloadAttachment, // 寻址发现:让模型选地址而不是拼地址 suggestAddress, listContacts, sessionParticipants, readThread, // 连接自愈 connectToServer, ]; }