Files
MailUI4Agents/plugins/zcode-mail-bridge/lib/tools.mjs
JianFeeeee be0693821b fix(agents): 四家桥的 read_inbox 一律按会话收窄(dsh/opencode/zcode/homeagent)
用户:「你还是没修好不同 session agent 收件箱隔离的问题」。上一轮我只修了 **pi**,
另外四家还漏着 —— 它们是**每一家各自实现** read_inbox,不修就还是漏。

## 缺陷

列表按 Agent 列(整个收件箱),而 read_inbox 按契约把**列出来的都标成已读**
⇒ A 会话的回合会把 B 会话的未读标掉 ⇒ B 之后按 `?status=unread` 补投时
再也看不到那封信(静默丢信,不是"少看一封")。用户是在别的 Agent 上看到它的。

## 四家的修法(各自平台能力不同,但都要"并发安全")

| 桥 | 会话来源 | 为什么这样做 |
|---|---|---|
| dsh | 工具第二参数 `exec.agent.id` → `reverseMap` | 平台就在上下文里给了会话;**不能用模块级"当前会话"变量**(同进程可能同时跑多条会话的回合,会互相覆盖) |
| opencode | 工具第二参数 `context.sessionID` → `reverseMap` | 同上 |
| zcode | `AGENTMAIL_SESSION_ID`(在**调用时**读) | 一轮一个进程,驱动本来就注入它给授权钩子用;调用时读,避免将来复用进程拿到旧值 |
| homeagent | `p.currentSessionID`(回合开始设、结束清) | Go 插件,本来就有这个状态 |

取不到会话一律**退回整体收件箱**(历史行为),不猜 —— 猜错就是静默丢信。

## 判据

- 服务端语义:`server/internal/repo/session_scope_test.go`(读 A 不动 B、列表收窄、
  计数与列表口径一致)。
- 桥侧接线:dsh 4 条、opencode 3 条、zcode 3 条、homeagent Go 1 条
  (`TestInboxURLScopedBySession`,直接断言拼出来的 URL)。
  每家都带**判据自检**:拿旧写法喂进来必须判红;dsh/opencode 还专门断言
  "不得用模块级当前会话变量"。
- **部署件**(不是仓库):四家的部署快照里都能 grep 到 `session_id=`。
- **线上实测**:用 opencode 自己的 Agent 身份请求收窄列表 —— 会话 A 3 封、
  会话 B 0 封、两者无交集、且都是全量的子集。

套件:opencode **331**、dsh **381**、zcode **385**、homeagent ok,全绿。
四家桥已重新部署(dsh/opencode/zcode 快照切换 + homeagent 新 plugin.bin 并重启),
四个服务均 active。
2026-09-14 12:07:45 +08:00

456 lines
19 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.

/**
* 暴露给 ZCode 模型的 AgentMail 工具。
*
* # 为什么工具集与另三个桥完全相同
*
* 同一件事在不同平台上应该有同一种做法。工具名(`read_inbox` / `send_mail` /
* `download_attachment` …)、参数名、以及**渲染文本**都对齐 pi / dsh / opencode
* 渲染走 `lib/inbox-format.js` 与 `lib/discovery.js`(逐字节同源),
* 所以模型在任一平台上看到的收件箱是同一个样子。
*
* 一旦这里少一个参数或换一种说法,就会出现「某个平台上模型不会回信」这类
* 只在单一平台复现的问题 —— 而排查时最费时间的正是「它到底和别的平台哪里不一样」。
*
* # 与平台无关
*
* 本模块不认识 MCP也不认识 ZCode它只是一组
* `{name, description, inputSchema, run(args) -> string}`。
* 协议那层在 lib/mcp-rpc.mjs入口在 mcp/server.mjs。
*/
import {
renderInbox,
renderMail,
idsToMarkRead,
formatSize,
DEFAULT_INBOX_STATUS,
DEFAULT_INBOX_LIMIT
} from './inbox-format.js';
import {
renderNameSuggestions,
renderPathSuggestions,
renderSessionSuggestions,
renderParticipants,
renderContacts,
renderThread
} from './discovery.js';
import { normalizeAttachmentIDs } from './attachment-ids.js';
import { uploadLocalFile, downloadToFile } from './gateway.mjs';
import { explicitSendsFile, noteExplicitSendFile } from './explicit-sends.mjs';
/** 正文在列表里的截断长度(与另三端一致)。 */
const BODY_LIMIT = 200;
const str = (v, fallback = '') => (typeof v === 'string' ? v : fallback);
const obj = v => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
/**
* MCP 工具的 `annotations`MCP 规范里的提示字段)。
*
* # 为什么这个字段在本项目里是**功能开关**而不是装饰
*
* ZCode 把 MCP 工具的风险参数这样算(逐字逆自 CLI 产物):
*
* annotations.readOnlyHint === true → riskLevel "low"
* annotations.destructiveHint === true → riskLevel "high"
* 两者都没有 → "medium"
* needsApproval = true ← **硬编码为真,与注解无关**
*
* 而它的档位判定是:
*
* build 档needsApproval || destructive || sideEffectScope !== "none" → **ask**
* plan 档permissionName === "mcp" && !destructive → **allow**
*
* 两条合起来推出一个不那么直观的结论:
*
* 在 `build` 档下,**每一个 MCP 工具都会要求审批**needsApproval 恒为真),
* 而 headless 模式没有交互式审批客户端 —— 于是全被拒。
* 在 `plan` 档下,**只要不声明 destructiveMCP 工具直接放行**。
*
* 所以 `destructiveHint` 的取值直接决定工具能不能用。声明时必须按真实语义:
* 这些工具都不销毁任何东西(读信、发信、传附件、查地址),所以是 false
* 只有真的会破坏用户环境的能力(比如替模型跑 shell 命令)才该是 true。
*/
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true };
const WRITE_SAFE = { readOnlyHint: false, destructiveHint: false, idempotentHint: false };
/** 构造工具集。
*
* @param {{client: import('./gateway.mjs').GatewayClient, agentName: string}} deps
*/
export function buildTools({ client, agentName }) {
/**
* 每次调用前校验配置。缺密钥时在此明确报错 ——
* 否则模型看到的是一个 401而它会去重试而不是告诉人「插件没配密钥」。
*/
const guard = () => {
const missing = client.checkConfig();
if (missing.length) {
throw new Error(
`AgentMail 未配置完成:缺少 ${missing.join('、')}` +
`请在 ZCode 的插件设置里填写,或为 ZCode 进程设置同名环境变量。`
);
}
};
const tools = [];
// ─── 读 ────────────────────────────────────────────────────────
tools.push({
name: 'read_inbox',
annotations: READ_ONLY,
description:
'查阅收件箱中的邮件。收到新邮件通知后应立即调用此工具。' +
'每封含 mail_id、发件人、主题、正文与附件清单带 attachment_id。',
inputSchema: {
type: 'object',
properties: {
status: { type: 'string', description: '过滤条件 unread|all默认 unread' },
limit: { type: 'number', description: '返回数量,默认 5' }
}
},
async run(args) {
guard();
const a = obj(args);
const status = str(a.status) || DEFAULT_INBOX_STATUS;
const limit = Number.isFinite(a.limit) ? a.limit : DEFAULT_INBOX_LIMIT;
// ★ 会话收窄:驱动每轮都会把**本轮邮件会话**注入 AGENTMAIL_SESSION_ID
// 授权钩子本来就用它MCP 子进程继承同一个 env ⇒ 这里直接读即可,
// 而且天然并发安全(一轮一个进程)。
//
// 缺陷(用户报的):「不同 session 的 agent 都可以看到全部邮件」:列表按 Agent 列,
// 且 read_inbox 会把列出的都标已读 ⇒ A 会话标掉 B 会话的未读 ⇒ B 之后按
// ?status=unread 补投时再也看不到那封信(静默丢信)。
// 在调用时读(而不是 import 时读死),避免未来复用同一进程时拿到旧值。
const mailSessionID = process.env.AGENTMAIL_SESSION_ID || '';
const scope = mailSessionID ? `&session_id=${encodeURIComponent(mailSessionID)}` : '';
const { mails } = await client.get(
`/mail/inbox?status=${encodeURIComponent(status)}&limit=${limit}${scope}`
);
const listed = renderInbox(mails, BODY_LIMIT, agentName);
const ids = idsToMarkRead(a.status, mails);
if (ids.length) {
// 标记失败不该让读取失败:正文已经取到了,代价只是下次重复看到。
client.post('/mail/read', { mail_ids: ids }).catch(() => {});
}
return listed;
}
});
tools.push({
name: 'read_mail',
annotations: READ_ONLY,
description: '读取一封邮件的完整正文、附件清单与可投递地址mail_id 从 read_inbox 获得)。',
inputSchema: {
type: 'object',
properties: { mail_id: { type: 'string', description: '要读哪封' } },
required: ['mail_id']
},
async run(args) {
guard();
const id = str(obj(args).mail_id);
if (!id) throw new Error('缺少 mail_id');
const data = await client.get(`/agent/mail/${encodeURIComponent(id)}?body_limit=0`);
const mail = data?.mail || data;
const lines = [renderMail(mail, 0, agentName)];
if (Array.isArray(data?.participants) && data.participants.length) {
lines.push('', renderParticipants(data));
}
if (data?.reply_address) {
lines.push('', `回信给发件人用 ${data.reply_address},或传 reply_to=${id}`);
}
return lines.join('\n');
}
});
tools.push({
name: 'read_thread',
annotations: READ_ONLY,
description: '查看一封邮件所在线索的完整往来(谁回了谁、谁还没回)。多方协作时用它避免重复提问。',
inputSchema: {
type: 'object',
properties: {
mail_id: { type: 'string', description: '线索中任一封邮件的 ID' },
offset: { type: 'number', description: '分页偏移,续取时传上次返回的 next_offset' }
},
required: ['mail_id']
},
async run(args) {
guard();
const a = obj(args);
const id = str(a.mail_id);
if (!id) throw new Error('缺少 mail_id');
const qs = Number.isFinite(a.offset) ? `?offset=${a.offset}` : '';
const data = await client.get(`/agent/mail/${encodeURIComponent(id)}/thread${qs}`);
return renderThread(data, agentName);
}
});
// ─── 写 ────────────────────────────────────────────────────────
tools.push({
name: 'send_mail',
annotations: WRITE_SAFE,
description:
'发送邮件。三维地址 name@path.session省略 session 投递到默认会话,' +
'.new 强制新建,.具体别名 必须已存在。回复来信请传 reply_to。',
inputSchema: {
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: {
// 声明成「数组或字符串」而不是纯数组:
// 模型常把数组写成 JSON 字符串(`"[\"id\"]"`
// opencode 上就是这样连试 6 次失败、最后放弃整个任务。
// 声明放宽 + 下面归一,两条一起才拦得住。
description: '附件 ID 列表(先用 upload_attachment 取得)',
anyOf: [
{ type: 'array', items: { type: 'string' } },
{ type: 'string', description: '单个 ID或形如 ["a","b"] 的 JSON 数组字符串' }
]
},
max_rounds: { type: 'number', description: '给这条新会话设定往返预算(仅新建时有效)' }
},
required: ['to', 'subject', 'body']
},
async run(args) {
guard();
const a = obj(args);
const to = str(a.to);
const subject = str(a.subject);
const body = str(a.body);
if (!to || !subject || !body) throw new Error('缺少必填字段to, subject, body');
const payload = { to, subject, body };
if (str(a.cc)) payload.cc = str(a.cc);
if (str(a.reply_to)) payload.reply_to = str(a.reply_to);
if (str(a.session_alias)) payload.session_alias = str(a.session_alias);
if (Number.isFinite(a.max_rounds)) payload.max_rounds = a.max_rounds;
const ids = normalizeAttachmentIDs(a.attachment_ids);
if (ids.length) payload.attachment_ids = ids;
const result = await client.post('/mail/send', payload);
// 记下「模型自己发了信」—— 驱动据此决定要不要再自动转发本轮收尾话。
// 两边是两个进程(工具跑在 ZCode 起的 MCP 服务器里),只能经文件对齐;
// 不记的后果是收件箱里出现两封说同一件事的邮件(线上实测过)。
noteExplicitSendFile(explicitSendsFile(process.env), {
sessionId: process.env.AGENTMAIL_SESSION_ID || result?.session_id || '',
to,
replyTo: str(a.reply_to),
ts: Date.now()
});
const parts = [`邮件已发送ID: ${result?.mail_id ?? '?'}`];
if (result?.session_id) parts.push(`,会话: ${result.session_id}`);
if (result?.session_alias) parts.push(`,别名: ${result.session_alias}`);
parts.push(')。');
if (result?.budget_remaining !== undefined) {
parts.push(`本任务剩余往返:${result.budget_remaining}`);
}
return parts.join('');
}
});
tools.push({
name: 'forward_mail',
annotations: WRITE_SAFE,
description:
'转发一封邮件给新的收件人(自动引用原文与附件)。与回复不同:回复落回原会话,转发按目标地址另行定位会话。',
inputSchema: {
type: 'object',
properties: {
mail_id: { type: 'string', description: '要转发的邮件 ID' },
to: { type: 'string', description: '新收件人的三维地址' },
comment: { type: 'string', description: '转发说明,置于引用原文之前' }
},
required: ['mail_id', 'to']
},
async run(args) {
guard();
const a = obj(args);
if (!str(a.mail_id) || !str(a.to)) throw new Error('缺少 mail_id 或 to');
const result = await client.post(
`/mail/${encodeURIComponent(str(a.mail_id))}/forward`,
{ to: str(a.to), comment: str(a.comment) }
);
return `已转发(新邮件 ID: ${result?.mail_id ?? '?'},会话: ${result?.session_id ?? '?'})。`;
}
});
// ─── 附件 ──────────────────────────────────────────────────────
tools.push({
name: 'upload_attachment',
annotations: WRITE_SAFE,
description:
'上传本地文件作为邮件附件,返回 attachment_id。' +
'拿到 id 后必须在 send_mail 的 attachment_ids 里带上,附件才会随邮件发出。',
inputSchema: {
type: 'object',
properties: { file_path: { type: 'string', description: '本地文件绝对路径' } },
required: ['file_path']
},
async run(args) {
guard();
const p = str(obj(args).file_path);
if (!p) throw new Error('缺少 file_path');
const a = await uploadLocalFile(client, p);
return (
`已上传 ${a.filename}${formatSize(a.size_bytes)}。attachment_id: ${a.attachment_id}\n` +
`在 send_mail 的 attachment_ids 里带上这个 id 才会随邮件发出。`
);
}
});
tools.push({
name: 'download_attachment',
annotations: WRITE_SAFE,
description: '下载邮件附件到本地文件。attachment_id 从 read_inbox 的附件清单里取。',
inputSchema: {
type: 'object',
properties: {
attachment_id: { type: 'string', description: '附件 ID' },
save_path: { type: 'string', description: '保存到的本地绝对路径' }
},
required: ['attachment_id', 'save_path']
},
async run(args) {
guard();
const a = obj(args);
const id = str(a.attachment_id);
const save = str(a.save_path);
if (!id || !save) throw new Error('缺少 attachment_id 或 save_path');
const size = await downloadToFile(client, id, save);
return `已保存到 ${save}${formatSize(size)}`;
}
});
// ─── 寻址发现 ──────────────────────────────────────────────────
tools.push({
name: 'suggest_address',
annotations: READ_ONLY,
description:
'查询可用的收件人地址,用于精准发信。不带参数给候选收件人名;带 name 给它可用的' +
'工作目录name+path 都带则给该目录下可续谈的会话与现成地址。',
inputSchema: {
type: 'object',
properties: {
name: { type: 'string', description: '收件人名,如 pi / admin' },
path: { type: 'string', description: '工作目录绝对路径' }
}
},
async run(args) {
guard();
const a = obj(args);
const name = str(a.name);
const path = str(a.path);
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()}`);
if (!name) return renderNameSuggestions(data?.names || data?.suggestions || []);
if (!path) return renderPathSuggestions(data?.paths || [], name);
return renderSessionSuggestions(data, name, path);
}
});
tools.push({
name: 'list_contacts',
annotations: READ_ONLY,
description: '列出自己参与过的全部会话及各自的可投递地址、未读数、剩余往返预算。',
inputSchema: {
type: 'object',
properties: { limit: { type: 'number', description: '最多列出多少条,默认 20' } }
},
async run(args) {
guard();
const limit = Number.isFinite(obj(args).limit) ? obj(args).limit : 20;
const data = await client.get(`/agent/contacts?limit=${limit}`);
return renderContacts(data, limit);
}
});
tools.push({
name: 'session_participants',
annotations: READ_ONLY,
description:
'列出某条会话的全部参与方(发件人/收件人/抄送方)及各自的可投递地址,并标出谁还没回应。' +
'要回给抄收方或向第三方转达时先用它拿地址。',
inputSchema: {
type: 'object',
properties: { session_id: { type: 'string', description: '会话 ID' } },
required: ['session_id']
},
async run(args) {
guard();
const sid = str(obj(args).session_id);
if (!sid) throw new Error('缺少 session_id');
const data = await client.get(`/agent/sessions/${encodeURIComponent(sid)}/participants`);
return renderParticipants(data);
}
});
// ─── 连接与登记 ────────────────────────────────────────────────
tools.push({
name: 'connect_to_server',
annotations: WRITE_SAFE,
description:
'连接到 AgentMail Gateway用当前配置的身份完成登记并报告连通性。' +
'首次安装或换了 Gateway 地址时调用。',
inputSchema: {
type: 'object',
properties: {
gateway_url: { type: 'string', description: 'Gateway 地址;省略则用当前配置' },
key_token: { type: 'string', description: '管理员签发的 Agent 密钥;省略则用当前配置' }
}
},
async run(args) {
// 刻意**不走 guard**:密钥没配好时,这个工具正是用来把问题说清楚的那个。
// 若也直接抛「未配置完成」,模型只能转述一句抱怨,人不知道该去哪里填。
const a = obj(args);
const url = (str(a.gateway_url) || client.baseURL).replace(/\/+$/, '');
const key = str(a.key_token) || client.agentKey;
const missing = client.checkConfig();
if (!agentName || (!key && !client.agentSecret)) {
return (
`AgentMail 尚未配置完成:缺少 ${missing.join('、')}\n` +
`请在 ZCode 的插件设置里填写,或为 ZCode 进程设置同名环境变量后重启。\n` +
`(当前解析到的 Gateway 地址:${url}`
);
}
const res = await fetch(`${url}/api/v1/agent/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(key
? { Authorization: `Bearer ${key}` }
: { 'X-Agent-Secret': client.agentSecret })
},
body: JSON.stringify({ name: agentName, platform: 'zcode' })
});
const text = await res.text();
let data = {};
try {
data = text ? JSON.parse(text) : {};
} catch {
data = {};
}
if (!res.ok) {
return `登记失败HTTP ${res.status}${data.error || data.message || text.slice(0, 200)}`;
}
return `已连接 ${url},身份 ${agentName}(状态:${data.status || 'ok'})。`;
}
});
return tools;
}
/** 便捷:把工具集变成 `name -> tool` 的映射,供分发层使用。 */
export function indexTools(tools) {
return new Map(tools.map(t => [t.name, t]));
}