Files
MailUI4Agents/plugins/pi-mail-bridge/src/tools.mjs
JianFeeeee 79c4171c9d feat: L0 线协议冻结 + 附件链路修复 + 人/Agent 区分
L0 核心:
- 严格解码 Decode(DisallowUnknownFields) 全覆盖 29 个 DecodeBody 调用点
- DecodeLenient 心跳专用:容忍新字段但回报 unknown_fields
- 400 消息列出本端点接受的全部字段(jsonFieldNames 反射 tag)
- 日历 status 校验(create 补字段 + update 拦非法值)
- 新增 strictdecode_test.go 10 例 + blob/list_test.go 6 例

A-4 附件挂载回滚:checkAttachable 在 CreateMail 前校验,失败按
解挂→释放 relay→删邮件→退预算回滚,幽灵邮件这条路堵住了

A-5 反向 GC:blob.Store.List() 枚举磁盘(跳 .upload-*),
SweepUnreferencedBlobs 按 attachments + calendar_attachments 反查,
48h 年龄下限兜上传窗口。已接进每小时 sweep 循环

C 人/Agent 区分:四个读路径 + threadCols 补 from_human / to_human
(EXISTS users 判定),models.Mail 加 ToHuman。前端判据从
workspace 启发式改成显式布尔,mailCounterpart/sessionCounterpart
从 session_workspace 取 path(修 dsh@dsh 拼接 bug)

契约文档:SSE new_mail 补 4 字段(in_reply_to/from_human/
permission_mode/permission_enforcement),B-5 加 B-5.6
(Agent→Agent 不转发),B-3.4 MUST 改条件式,心跳补 mode_enforcement
+ unknown_fields,demo 死链修复 + from_human 检查
验收清单加 Agent→Agent 负向对照项
2026-09-06 15:18:06 +08:00

476 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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.

/**
* 邮件工具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
* 会让 typeboxpi-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_idPOST /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: '附件 IDread_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_permissionN-1 / T-7
// 权限询问由 tool_call 钩子接管 —— 模型可能忘了调,也可能在不需要时乱调,
// 而真正被 pi 拦下的那一次才是事实。
return [
sendMail, readInbox, readMail, forwardMail,
uploadAttachment, downloadAttachment,
// 寻址发现:让模型选地址而不是拼地址
suggestAddress, listContacts, sessionParticipants, readThread,
// 连接自愈
connectToServer,
];
}