Files
MailUI4Agents/plugins/pi-mail-bridge/src/tools.mjs
JianFeeeee 552fbc731e fix(inbox): read_inbox 按会话收窄 —— 修「不同 session 的 agent 都能看到全部邮件」
用户问:「你之前不是说你已经处理了不同 session 的 agent 都可以看到全部邮件的
问题了吗?」——**我得先纠正事实:上一轮我只做了诊断并问要不要动手,没有实施。**
这是我的表述问题(把"已定位并给了方案"说成了像"已处理")。现在实施。

## 缺陷

`read_inbox` 是**按 Agent** 的:列的是该 Agent 的全部未读(含别的会话的来信),
并按契约把列出来的都标成已读 ⇒ A 会话的 worker 标掉 B 会话的未读。平时看不出来
(SSE 事件在途时队列兜着),但桥重启/漏事件后的补投判据是 `?status=unread` ——
被标掉的那封**再也不会补投** ⇒ 静默丢信。现场实例:另一条会话的来信在
`mail_reads` 里的 reader=pi、时间正是我读自己收件箱的那一刻。

## 改动

- **网关**:`GET /mail/inbox` 与 `POST /mail/read` 支持可选 `session_id`。
  不带 = 旧语义(整个 Agent 的收件箱,浏览器/脚本仍可用);带了就只在这条会话内
  列与标。`ListInbox` / `MarkAllInboxReadFor` 保持原签名并委托给新变体 ——
  老调用点一个都不用改。
- **pi 桥**:`read_inbox` 把自己那条会话拼进 URL(worker 通过闭包把**邮件会话 id**
  递给工具,而不是在启动时取快照)。

## 判据

- repo 三条:列表按会话收窄(含"不带会话时两条都在"的反向对照)、
  ★"标会话 A 不动会话 B"、会话内计数与列表口径一致(否则界面会出现"徽标 2、列表 1")。
- handler/网关:非法 `session_id` ⇒ 400(不静默忽略)。
- pi 接线三条(URL 拼了收窄、worker 递了 id、判据自检:旧写法必须判红)。
- 线上只读 E2E:两条真实会话 A/B 列表**无交集**、不带会话能列出全部、非法 id 400。

## 过程中测试当场抓到"只改了一半"

`MarkAllInboxReadForSession` 里插 `mail_reads` 的语句我加了会话条件,
**刷新冗余列的 UPDATE 忘了加** ⇒ 返回的"标掉几封"变成 2(应 1)。
判据一眼看出来了 —— 这类"改一半"正是这次要防的。

## 范围(诚实说明)

另外四家桥(dsh/opencode/zcode/homeagent)的 `read_inbox` 工具签名里**没有会话上下文**
(`execute(args)` / `execute(args, ctx)` 各不相同),要按各自框架的上下文 API 接线,
不是一行改动 ⇒ **未做**,列为待办(位置已定位)。所以:pi 上这个缺陷已消除,
另外四家仍在。

## 部署

网关已部署并线上验证;pi 桥的部署**延迟到本轮结束后 150 秒**执行
(重启 pi 桥会掐掉我自己这一轮 —— 之前真发生过),日志
`/var/log/agentmail-pi-redeploy.log`,可用 `node deploy/check-deploy-drift.mjs` 核对。
2026-09-14 09:19:25 +08:00

487 lines
22 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 { normalizeAttachmentIDs } from '../lib/attachment-ids.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, getMailSessionId = () => ''}) {
// 收件箱列表的会话收窄参数。
//
// ★ 缺了它,这条列表会把**别的会话**的来信一起列出来并按契约标成已读 ——
// 别的会话的 worker 之后按 ?status=unread 补投时就再也看不到那封信。
// 用户原话:「不同 session 的 agent 都可以看到全部邮件」。
const inboxScope = () => {
const sid = typeof getMailSessionId === 'function' ? getMailSessionId() : '';
return sid ? `&session_id=${encodeURIComponent(sid)}` : '';
};
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: normalizeAttachmentIDs(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}${inboxScope()}`,
);
// 渲染与已读策略走共用模块:与另两个平台必须一致,
// 每条规则对应过一次真实的错误行为(见 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,
];
}