feat(zcode): AgentMail 的 ZCode 插件 —— MCP 工具面 + 官方宿主启动验证

ZCode 用插件扩展能力(.zcode-plugin/plugin.json 声明 skills/commands/hooks/
mcpServers),所以适配它的正确形状是**插件**而不是又一个独立桥进程。

本提交是第一步:把 AgentMail 的工具面做成 MCP 服务器。

协议层(lib/mcp-rpc.mjs)手写,不引 @modelcontextprotocol/sdk:
协议面只有 initialize / notifications/initialized / tools/list / tools/call,
手写可省掉一条构建链与 1MB 打包产物(与 pi/opencode/dsh 三桥零运行时依赖的
取向一致),并让这一层成为可穷举的纯函数。分帧照官方插件产物实测确认是
换行分隔 JSON(Content-Length 出现 0 次,StdioServerTransport + split("\n"))。

工具面(lib/tools.mjs)与另三个桥**同名同参**,渲染走共用的
addressing/inbox-format/discovery(逐字节同源,已纳入 check-shared-libs.sh)。
测试里有一条断言直接拿 pi 桥的工具名做对照:少一个就让某平台行为与其它平台不同,
那种问题只在单平台复现,排查代价最高。

两处按真实缺陷定的行为:
- 工具失败回 result+isError 而非 JSON-RPC error —— 后者会让模型看不到失败原因,
  只能重试(opencode 连试 6 次发不出附件正是这个后果)
- attachment_ids 声明放宽为 anyOf 数组/字符串并在桥侧归一 —— 模型常写成
  JSON 字符串,服务端严格解码会拒(同样来自 opencode 那次失败)

入口 mcp/server.mjs 修掉一个真实缺陷:stdin 关闭即 process.exit 会杀掉在途请求,
表现为「协议全对但访问网关的调用完全没有响应」。现按在途计数 drain,
且把 stdout 写入也计入,避免最后一条响应卡在缓冲区。

顺带修 check-shared-libs.sh 的一个既有假绿:本机 PATH 上的 diff 是鸿蒙 SDK
工具链的 diff,不认 -q 且对不同的文件仍返回 0 —— 于是该检查器**一直是永真输出**。
改用 cmp -s,并加自检(判据本身必须先被证明能发现差异)。反向验证:
让 zcode 或 pi 的共用模块分叉,检查器都正确报错并返回 1。

验证:
- 单元 33 项 + 继承共用测试 87 项 = 120/120
- `zcode plugins list` → agentmail@inline [enabled],mcp: plugin:agentmail:agentmail
- 经官方 `node zcode.cjs __zcode-plugin-host <server.mjs>` 启动 → 握手与 tools/list 正常
- 真实网关调用:以 zcode 身份 read_inbox / suggest_address / list_contacts 均返回
This commit is contained in:
2026-09-12 13:47:51 +08:00
parent 9204f019a1
commit e0e6f86d94
19 changed files with 3011 additions and 8 deletions

View File

@ -0,0 +1,141 @@
/**
* 三维寻址的构造与判读 —— 所有平台插件共用。
*
* 为什么这些函数必须共用、且必须是纯函数:
*
* 地址拼错不会报错。`name@path.session` 的每一段都可以省略,任何组合都能被
* `ParseAddress` 解析出**某个**结果,于是拼错的代价不是失败而是**投到别处**。
* 生产上真实发生过两次:
*
* 1. 插件把 `.new` 原样当作回信地址 —— `.new` 是一次性动作,回过去只会
* 再建一条平行会话,双方从此各说各话。
* 2. path 为空时朴素拼接得到 `admin.silent-harbor` —— 没有 `@`
* 整串被当成名字session 位静默丢失。
*
* 两次都是「拼字符串」造成的,所以拼地址这件事收进这里,各平台不再自己拼。
*/
/**
* 拼一个可寻址的 `name@path.session`。
*
* **空 path 也必须留下 `@` 与 `.`**`admin@.silent-harbor` 才解析成
* name=admin path="" session=silent-harbor。省掉 `@` 得到的
* `admin.silent-harbor` 会被整串当作名字。
*
* session 省略时不写那一位(默认会话语义)。
*
* @param {string} name 收件方名Agent 名或人类用户名)
* @param {string} [path] 工作目录,可为空
* @param {string} [session] 会话别名;空则省略该位
* @returns {string} 地址name 为空时返回空串
*/
export function formatAddress(name, path, session) {
const n = String(name ?? '').trim();
const p = String(path ?? '').trim();
const s = String(session ?? '').trim();
if (!n) return '';
if (!s) return p ? `${n}@${p}` : n;
return `${n}@${p}.${s}`;
}
/**
* 判断自己在这封邮件里是收件人还是抄送方。
*
* 为什么需要它:被抄送方与主收件人的**职责不同**。线上那封联调邮件里,
* admin 主发 dsh、抄送 opencode分工是「dsh 提供源码解读、opencode 提供部署
* 现状、最后由 dsh 汇报」。收件箱若不区分身份,两方都会以为自己是负责人,
* 或者都以为自己只是旁观者。
*
* @param {any} mail `/mail/inbox` 返回的一封邮件
* @param {string} selfName 自己的 Agent 名
* @returns {'to'|'cc'|'unknown'}
*/
export function roleOf(mail, selfName) {
const self = String(selfName ?? '').trim();
if (!self) return 'unknown';
if (mail?.to_name === self) return 'to';
if (Array.isArray(mail?.cc_list) && mail.cc_list.some(c => c?.name === self)) {
return 'cc';
}
return 'unknown';
}
/**
* 给出「把回信发回这条会话」的地址。
*
* 发件人一侧**不带 path**Agent 回信时 `from_workspace` 存的是 Agent 名而不是
* 路径(历史遗留),拿它拼会得到 `dsh@dsh.alias` 这种投不出去的东西。
* 人类发件人本来就没有工作目录。
*
* 别名为空时退回 `name`(默认会话)而不是编一个 —— 但注意这与「投回同一条会话」
* 不等价,默认会话是该 name 当前最活跃的那条。调用方要区分时看返回值有没有 `.`。
*
* @param {any} mail 一封邮件
* @param {string} [alias] 会话别名,缺省取 mail.session_alias
* @returns {string}
*/
export function replyAddressFor(mail, alias) {
const a = alias ?? mail?.session_alias ?? '';
return formatAddress(mail?.from_name, '', a);
}
/**
* 给出自己在这条会话里的地址,供转发说明或向第三方引用时使用。
*
* 用 `to_workspace`(自己那个地址的 path 位)而不是发件人的:
* 抄送给 `opencode@/a` 与主发给 `dsh@/b` 是两个不同的工作区。
*
* @param {any} mail 一封邮件
* @param {string} selfName 自己的 Agent 名
* @param {string} [alias] 会话别名,缺省取 mail.session_alias
* @returns {string}
*/
export function selfAddressFor(mail, selfName, alias) {
const a = alias ?? mail?.session_alias ?? '';
// 抄送方拿到的 to_workspace 是主收件人的,自己的 path 在 cc_list 里。
// 不取对的那个会让「我是谁」这句话指向别人的工作目录。
let path = mail?.to_workspace ?? '';
if (mail?.to_name !== selfName && Array.isArray(mail?.cc_list)) {
const mine = mail.cc_list.find(c => c?.name === selfName);
if (mine) path = mine.path ?? '';
}
return formatAddress(selfName, path, a);
}
/**
* 列出这封邮件的全部参与方及各自可投递的地址。
*
* 这是「回给抄收方」缺的那块信息:知道有谁,**以及用什么地址找到他**。
* 抄送方的 path 取它自己那个地址的 path 位。
*
* 自己会被标 `is_self`,而不是从列表里剔掉 —— 剔掉的话模型无法确认
* 「这封信是不是也发给了我」,也就无法判断自己是不是该回。
*
* @param {any} mail 一封邮件
* @param {string} [selfName] 自己的名字,用于标记 is_self
* @param {string} [alias] 会话别名,缺省取 mail.session_alias
* @returns {{role: string, name: string, path: string, address: string, is_self: boolean}[]}
*/
export function participantsOfMail(mail, selfName, alias) {
const a = alias ?? mail?.session_alias ?? '';
const self = String(selfName ?? '').trim();
const out = [];
const add = (role, name, path) => {
const n = String(name ?? '').trim();
if (!n) return;
out.push({
role,
name: n,
path: String(path ?? ''),
address: formatAddress(n, path, a),
is_self: !!self && n === self,
});
};
// 发件人一侧 path 留空,理由同 replyAddressFor
add('from', mail?.from_name, '');
add('to', mail?.to_name, mail?.to_workspace);
if (Array.isArray(mail?.cc_list)) {
for (const c of mail.cc_list) add('cc', c?.name, c?.path);
}
return out;
}

View File

@ -0,0 +1,72 @@
/**
* 把工具参数里的附件 id 列表归一成 `string[]`。
*
* # 为什么需要它(生产实测)
*
* opencode 上一轮把四个步骤全做完了 —— 下载两个附件、读出内容、上传回传文件 ——
* 却在最后一步卡住:`send_mail` 连续 **6 次**失败,模型自己总结为
* 「attachment_ids 参数有框架级序列化 bug」然后放弃了整个任务。
*
* 真实原因不是框架 bug而是模型把数组写成了 **JSON 字符串**
*
* attachment_ids = "[\"10e73e9f-c2a9-4226-bdb5-34ef1b340eb8\"]"
*
* 桥把这个字符串原样转发给服务端,服务端的严格解码器按契约拒收
* `字段 "attachment_ids" 类型不对:期望 string 数组,收到 string`)。
*
* # 修在哪一层
*
* **不在服务端放宽。** 服务端那个"严格"是刻意的,它挡的是字段名拼错、结构写错
* 这类真错误 —— 松开之后真 bug 会被静默接受同一封邮件少几个附件HTTP 仍是
* 200
*
* **在桥这一层收。** 桥是适配器:模型侧的形状天生不可靠(它按自然语言直觉填
* 参数),而适配器的职责就是把不可靠的输入归一成契约要求的形状。对模型宽容、
* 对服务端严格,这与 homeagent 那个 Go 插件里的 `stringList` 是同一个判断
* (那边的注释写着:「也接受单个字符串……拒绝它只会换来一次重试,而意图毫无
* 歧义」)。
*
* # 接受的形状
*
* - `["a", "b"]` 数组
* - `"[\"a\", \"b\"]"` JSON 数组字符串 ← **本次事故的形状**
* - `"a"` 单个 id
* - `"a, b"` / `"a b"` 逗号或空白分隔
* - `[null, "a", 3]` 混进杂质:丢掉坏的、留下好的
*
* 逐项过滤而不是整体放弃:三个附件里有一个写坏,不该变成"一个都不发"。
* 空串与 null 一并丢掉 —— 服务端的 parseAttachmentIDs 也跳过空串,
* 与它保持一致,免得插件放过去的东西换个形状在服务端再失败一次。
*
* @param {unknown} value 工具参数里的原始值
* @returns {string[]} 归一后的 id 列表(永不为 null
*/
export function normalizeAttachmentIDs(value) {
if (value === null || value === undefined) return [];
if (Array.isArray(value)) return value.flatMap(normalizeAttachmentIDs);
if (typeof value !== 'string') return [];
const raw = value.trim();
if (raw === '') return [];
// JSON 数组字符串:本次事故的形状。解析失败不算错 —— 它可能就是普通 id。
if (raw.startsWith('[')) {
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) return parsed.flatMap(normalizeAttachmentIDs);
} catch {
// 落到下面的分隔符分支
}
}
// 逗号/空白分隔:模型偶尔写成 "a, b"。
// 只在确实含分隔符时切分,避免把单个 id 切坏 —— uuid 里没有这些字符。
if (/[,\s]/.test(raw)) {
return raw
.split(/[,\s]+/)
.map(s => s.trim())
.filter(s => s !== '');
}
return [raw];
}

View File

@ -0,0 +1,208 @@
/**
* 有界容器 —— 给插件里那些「只增不减」的映射表兜底。
*
* # 为什么需要它
*
* 桥是**常驻进程**pi 的守护进程能跑几十天opencode/DSH 的插件跟着平台一起活)。
* 里面每一张 `Map`/`Set` 都在回答「这条会话/这封邮件我处理过吗」,键来自外部
* 事件流 —— 会话数与邮件数随时间单调增长,键却没有出口。
*
* 单条成本很小uuid 键 + 短字符串值,几十到几百字节),所以它不是几小时内撑爆
* 内存的那种故障。实际形态是:跑够久之后进程里躺着几十万个再也不会被查到的条目,
* 且 **GC 回收不了**(还被强引用着)。这类问题不会在开发和测试里出现,
* 只在生产上跑了几周后表现为「重启一下就好了」。
*
* # 淘汰策略:丢最久没被访问的
*
* JS 的 `Map`/`Set` 保证插入顺序,所以「删掉再插入」等价于「移到队尾」。
* 读也算访问(`get`/`has` 会刷新顺序),于是长期活跃的会话不会因为条目老被丢掉 ——
* 被淘汰的总是「很久没人问过」的那些。
*
* # 上限分表定义,因为丢一条的后果差别很大
*
* - `deliveredMails` 丢一条 → 那封邮件**理论上**可能被重复投递。但它防的两种
* 重复(心跳与 SSE 建连之间的窗口、SSE 断线重放)都发生在秒到分钟级,
* 几千封之前的 mail_id 不可能再来 —— 淘汰是安全的。
*
* - 会话级映射丢一条 → 那条会话下次来信时被当成新会话,平台侧上下文断掉。
* 这是**真的行为退化**,所以上限给得大得多,并且优先靠 `session_archived`
* 主动清理,让上限只当兜底。
*
* # 不要用它装「还在等结果的东西」
*
* 待决权限询问opencode 的 `pendingPermissions`、DSH 的 `pendingApprovals`
* 里存的是 `resolve` 回调。静默淘汰一条会让对应的 `await` **永远不返回** ——
* 平台侧那次工具调用就挂死了。那些表有确定的清理路径(决策到达 / 超时 / 拆插件
* 时 fail closed不该套上界。上界只适合「记录已经发生过的事实」的表。
*
* 三平台共用必须逐字节相同deploy/check-shared-libs.sh 校验)。
*/
/**
* 已投递邮件 id 的记忆上限。
*
* 2000 覆盖的是去重真正需要的时间窗SSE 重放最多回放服务端环形缓冲的 500 条
* 事件,一次补拉最多 5 封。留 2000 是三个数量级的余量,内存代价约 200KB。
*/
export const MAX_TRACKED_MAILS = 2000;
/**
* 会话级映射的条目上限。
*
* 淘汰一条会让那条会话失去平台侧上下文,所以这个数字要远大于「同时在推进的
* 任务数」。500 条 × 每条几百字节 ≈ 150KB —— 便宜到没有理由抠。
*
* 真正的清理来自 `session_archived`:会话归档后它的映射再无用处,那是确定性
* 时机;上限兜的是「一直不归档」。
*/
export const MAX_TRACKED_SESSIONS = 500;
function normalizeLimit(limit) {
const n = Number(limit);
// 上限必须是正整数0 会让每次 set 之后立刻把自己淘汰掉(表恒空,去重全部
// 失效且不报错NaN 会让 while 条件恒假(退化成无界)。两种都是静默的
// 错误行为,不如当场拒绝。
if (!Number.isFinite(n) || n < 1) {
throw new RangeError(`有界容器的上限必须是 >= 1 的整数,收到 ${limit}`);
}
return Math.floor(n);
}
/**
* 有界 Map超过上限时丢弃最久未访问的条目。
*
* 只实现桥里真正用到的那几个方法 —— 不做成 Map 的完整替身,那样会掩盖
* 「这张表是有界的」这个必须被看见的事实。
*/
export class BoundedMap {
/** @param {number} limit 条目上限 */
constructor(limit) {
this.limit = normalizeLimit(limit);
/** @type {Map<any, any>} */
this.map = new Map();
/** 累计淘汰条数,观测用(日志里能看出上限是否设得太小)。 */
this.evicted = 0;
}
get size() {
return this.map.size;
}
has(key) {
return this.map.has(key);
}
/**
* 取值并把该键移到队尾。
*
* 读也算访问:一条会话只要还在收信就会被反复 get不刷新的话它会因为
* 「插入得早」被淘汰 —— 那恰好淘汰了最该留的那些。
*/
get(key) {
if (!this.map.has(key)) return undefined;
const value = this.map.get(key);
this.map.delete(key);
this.map.set(key, value);
return value;
}
/** 取值但**不**刷新顺序。给「只是想看一眼」的场合。 */
peek(key) {
return this.map.get(key);
}
set(key, value) {
// 已存在时先删Map 的 set 不改变已有键的位置,不删就刷不了活跃度。
if (this.map.has(key)) this.map.delete(key);
this.map.set(key, value);
while (this.map.size > this.limit) {
const oldest = this.map.keys().next().value;
this.map.delete(oldest);
this.evicted++;
}
return this;
}
delete(key) {
return this.map.delete(key);
}
clear() {
this.map.clear();
}
keys() {
return this.map.keys();
}
values() {
return this.map.values();
}
entries() {
return this.map.entries();
}
[Symbol.iterator]() {
return this.map[Symbol.iterator]();
}
}
/**
* 有界 Set超过上限时丢弃最久未访问的成员。
*
* `has` 也刷新顺序:与 `BoundedMap.get` 同理。对 `deliveredMails` 这意味着
* 「刚被去重挡下的那封」会留得更久,正合语义。
*/
export class BoundedSet {
/** @param {number} limit 成员上限 */
constructor(limit) {
this.limit = normalizeLimit(limit);
/** @type {Set<any>} */
this.set = new Set();
this.evicted = 0;
}
get size() {
return this.set.size;
}
has(value) {
if (!this.set.has(value)) return false;
this.set.delete(value);
this.set.add(value);
return true;
}
/** 判断存在但**不**刷新顺序。 */
peek(value) {
return this.set.has(value);
}
add(value) {
if (this.set.has(value)) this.set.delete(value);
this.set.add(value);
while (this.set.size > this.limit) {
const oldest = this.set.values().next().value;
this.set.delete(oldest);
this.evicted++;
}
return this;
}
delete(value) {
return this.set.delete(value);
}
clear() {
this.set.clear();
}
values() {
return this.set.values();
}
[Symbol.iterator]() {
return this.set[Symbol.iterator]();
}
}

View File

@ -0,0 +1,237 @@
/**
* 寻址发现工具 —— 所有平台插件共用的**纯逻辑**部分。
*
* 三个 Agent 侧只读端点(`/agent/contacts`、`/agent/contacts/suggest`、
* `/agent/sessions/{id}/participants`)的返回值怎么渲染给模型看,与平台 SDK 无关,
* 所以收进这里。各平台只负责把自己的工具定义壳套上去。
*
* # 这一组端点解决的问题
*
* 在它们存在之前,`send_mail` 的 `to` 是一个**只能靠记忆拼写的自由文本字段**。
* 人类侧从来不是这样三段式输入框逐段查候选name / path / session 每一段都从
* 活数据里选。Agent 只能猜,而猜错不会报错 —— 生产上 dsh 猜了
* `opencode@/home`,地址解析通过、投递成功,但那不是 opencode 的工作目录,
* 那个错误路径静默变成了新会话的 workspace。
*
* # 渲染的取舍
*
* 一律输出**可直接粘进 `to` 的完整地址**,而不是把三段分开列。模型看到
* `opencode@/home.silent-harbor` 会整串复制;看到 `name=opencode path=/home
* session=silent-harbor` 则要自己拼,而自己拼就是问题的来源。
*/
/**
* 渲染候选收件人清单(`kind: "name"`)。
*
* 只给名字,不给地址:此时还不知道 path 与 session硬拼出来的
* 裸名字地址会投到「默认会话」—— 那不一定是调用方想要的那条。
* 明确提示下一步该查什么,模型才会继续往下走而不是就地拼一个。
*
* @param {string[]} names
* @returns {string}
*/
export function renderNameSuggestions(names) {
const list = Array.isArray(names) ? names.filter(Boolean) : [];
if (list.length === 0) return '当前没有可投递的收件人。';
return [
`可投递的收件人(${list.length} 个):`,
list.map(n => `- ${n}`).join('\n'),
'',
'下一步:用 suggest_address 带上 name 查它可用的工作目录path 位)。',
].join('\n');
}
/**
* 渲染工作目录候选(`kind: "path"`)。
*
* 空列表要说清「这不代表不能发」path 位允许为空(人类用户没有工作目录),
* 不解释的话模型会卡在这一步,或者编一个路径出来。
*
* @param {string[]} paths
* @param {string} name 正在查的收件人名,用于拼下一步的提示
* @returns {string}
*/
export function renderPathSuggestions(paths, name) {
const list = Array.isArray(paths) ? paths.filter(Boolean) : [];
if (list.length === 0) {
return [
`${name} 没有记录在案的工作目录。`,
'这不代表不能给它发信 —— path 位可以留空(人类用户就没有工作目录)。',
`直接用 suggest_address(name="${name}", path="") 查它的会话,或直接发给 ${name}`,
].join('\n');
}
return [
`${name} 用过的工作目录(按最近使用排序):`,
list.map(p => `- ${p}`).join('\n'),
'',
`下一步:用 suggest_address(name="${name}", path="<上面某一个>") 查该目录下可续谈的会话。`,
].join('\n');
}
/**
* 渲染会话候选(`kind: "session"`)。
*
* **`addresses` 与 `suggestions` 同序**,服务端保证。这里优先用 `addresses`
* 那是服务端拼好的完整地址,插件不必自己拼(自己拼过一次,拼错了)。
*
* `new` 永远在最后且带一句警告:它不是一条已存在的会话。排在前面会让模型
* 在想续谈时顺手开出一条新线索 —— 生产上已经发生过。
*
* @param {object} data `/agent/contacts/suggest` 的返回体
* @param {string} name
* @param {string} path
* @returns {string}
*/
export function renderSessionSuggestions(data, name, path) {
const aliases = Array.isArray(data?.suggestions) ? data.suggestions : [];
const addresses = Array.isArray(data?.addresses) ? data.addresses : [];
const candidates = Array.isArray(data?.candidates) ? data.candidates : [];
// 只有 new 一项 = 这个 name@path 下还没有任何可续谈的会话
const existing = aliases.filter(a => a !== 'new');
if (existing.length === 0) {
return [
`${name}${path ? '@' + path : ''} 下还没有可续谈的会话。`,
`要开一条新线索用 ${addressAt(addresses, aliases, 'new') || `${name}@${path}.new`}`,
'并在 send_mail 里传 session_alias 给它命名,之后就能按名字续谈。',
].join('\n');
}
const lines = [`${name}${path ? '@' + path : ''} 下可续谈的会话:`];
for (let i = 0; i < aliases.length; i++) {
const alias = aliases[i];
const addr = addresses[i] || '';
const c = candidates[i] || {};
if (alias === 'new') continue; // new 单独放最后
const bits = [];
if (c.title) bits.push(c.title);
if (typeof c.unread === 'number' && c.unread > 0) bits.push(`${c.unread} 封未读`);
if (c.source === 'platform') bits.push('平台侧会话');
lines.push(`- ${addr || alias}${bits.length ? ` ${bits.join('')}` : ''}`);
}
lines.push('');
lines.push('把上面某个地址原样填进 send_mail 的 to 即可投进那条会话。');
const newAddr = addressAt(addresses, aliases, 'new');
if (newAddr) {
lines.push(`若确实要开一条**新**线索(而不是接着上面某条谈)才用 ${newAddr}`);
}
return lines.join('\n');
}
/** 按别名在同序的 addresses 里取地址。 */
function addressAt(addresses, aliases, alias) {
const i = aliases.indexOf(alias);
return i >= 0 ? addresses[i] || '' : '';
}
/**
* 渲染会话参与方清单。
*
* 这是「发送给抄收方 / 转发方」缺的最后一块:知道有谁、**用什么地址找到他**、
* 以及谁还没开口。`mail_count` 为 0 的那个就是还没回应的人 —— 服务端只数
* 「作为发件人」的邮件,正是为了让这个判断成立。
*
* @param {object} data `/agent/sessions/{id}/participants` 的返回体
* @returns {string}
*/
export function renderParticipants(data) {
const parts = Array.isArray(data?.participants) ? data.participants : [];
if (parts.length === 0) return '该会话还没有参与方(可能是一条刚建立的空会话)。';
const alias = data?.session_alias || '';
const lines = [`会话 #${alias || '未命名'} 的参与方:`];
for (const p of parts) {
const tags = [];
if (p.is_self) tags.push('就是你');
if (Array.isArray(p.roles) && p.roles.length) {
tags.push(p.roles.map(roleLabel).join('/'));
}
if (p.mail_count === 0 && !p.is_self) tags.push('尚未回应');
const addr = p.address ? p.address : '(无可投递地址:该会话尚未命名)';
lines.push(`- ${p.name} ${addr}${tags.length ? ` [${tags.join('')}]` : ''}`);
}
lines.push('');
lines.push('要联系其中某一方,把它的地址原样填进 send_mail 的 to。');
return lines.join('\n');
}
/**
* 渲染联系人清单(本 Agent 参与过的全部会话)。
*
* 按未读优先、其次最近活跃排序:模型问「我还有什么没处理」时,
* 有未读的那些才是答案。
*
* @param {object} data `/agent/contacts` 的返回体
* @param {number} limit 最多列出多少条
* @returns {string}
*/
export function renderContacts(data, limit = 20) {
const list = Array.isArray(data?.contacts) ? data.contacts.slice() : [];
if (list.length === 0) return '还没有任何往来会话。';
list.sort((a, b) => {
const ua = a?.unread_count || 0;
const ub = b?.unread_count || 0;
if (ua !== ub) return ub - ua;
return String(b?.last_activity || '').localeCompare(String(a?.last_activity || ''));
});
const shown = list.slice(0, limit);
const lines = [`往来会话(共 ${list.length}${list.length > shown.length ? `,列出前 ${shown.length}` : ''}`];
for (const c of shown) {
const bits = [];
if (c.unread_count > 0) bits.push(`${c.unread_count} 封未读`);
if (c.subject) bits.push(c.subject);
if (c.max_rounds > 0) {
const left = Math.max(0, c.max_rounds - (c.used_rounds || 0));
bits.push(`${left}/${c.max_rounds} 个来回`);
}
const addr = c.address || '(未命名会话,只能用 reply_to 续谈)';
lines.push(`- ${addr}${bits.length ? ` ${bits.join('')}` : ''}`);
}
return lines.join('\n');
}
/** 角色的中文标签。模型读到「抄送方」比读到 cc 更容易判对分工。 */
function roleLabel(role) {
switch (role) {
case 'from': return '发件人';
case 'to': return '收件人';
case 'cc': return '抄送方';
default: return String(role);
}
}
/**
* 渲染对话树,回答「谁已经回了、谁还没回」。
*
* 缩进表示层级。**detached 必须标出来**:那表示父邮件不在本次结果里
* (无权查看或尚未加载),不标的话模型会以为这是一条独立线索。
*
* @param {object} data `/agent/mail/{id}/thread` 的返回体
* @param {string} [selfName] 自己的名字,用于标出哪几封是自己发的
* @returns {string}
*/
export function renderThread(data, selfName = '') {
const nodes = Array.isArray(data?.nodes) ? data.nodes : [];
if (nodes.length === 0) return '这条线索上没有可见的邮件。';
const lines = [`线索共 ${data?.total ?? nodes.length}${data?.hidden ? `(另有 ${data.hidden} 封无权查看)` : ''}`];
for (const n of nodes) {
const depth = typeof n?.depth === 'number' ? Math.max(0, n.depth) : 0;
const indent = ' '.repeat(Math.min(depth, 8));
const marks = [];
if (selfName && n?.from_name === selfName) marks.push('你发的');
if (n?.mail_id === data?.anchor_mail_id) marks.push('当前这封');
if (n?.detached) marks.push(n.parent_hidden ? '父邮件无权查看' : '父邮件尚未加载');
lines.push(
`${indent}- ${n?.from_name ?? '?'}${n?.to_name ?? '?'}: ${n?.subject ?? '(无主题)'}` +
` [${n?.mail_id ?? '?'}]${marks.length ? ` (${marks.join('')})` : ''}`
);
}
if (data?.has_more) {
lines.push('');
lines.push(`还有更多,用 offset=${data.next_offset} 继续取。`);
}
return lines.join('\n');
}

View File

@ -0,0 +1,128 @@
/**
* AgentMail 网关的 HTTP 客户端。
*
* 与 pi / dsh / opencode 三个桥的同名模块**同一套请求头与端点**
* `Authorization: Bearer <key>` + `X-Agent-Name`,路径前缀 `/api/v1`)。
* 刻意不共用文件:那三个桥的客户端与各自平台的会话生命周期耦合,
* 而这个只服务 MCP 的请求-响应模型;共用的部分(地址解析、收件箱渲染、
* 附件 id 归一)已经抽在 `lib/` 里逐字节同源。
*/
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { dirname, basename } from 'node:path';
/** 与网关一致的默认值;无头部署时由 ZCode 的 userConfig / 环境变量覆盖。 */
const DEFAULT_BASE = 'http://127.0.0.1:8180';
export class GatewayError extends Error {
constructor(status, body, path) {
// 把状态码与响应体一起带上:只报「请求失败」会让模型无从改正
// (是密钥不对?会话别名被占?预算用尽?三种要完全不同的应对)。
super(`HTTP ${status} ${path}${body ? `${body}` : ''}`);
this.status = status;
this.body = body;
this.path = path;
}
}
export class GatewayClient {
constructor(env = process.env) {
this.baseURL = String(env.AGENTMAIL_GATEWAY_URL || DEFAULT_BASE).replace(/\/+$/, '');
this.agentKey = String(env.AGENTMAIL_AGENT_KEY || '').trim();
this.agentSecret = String(env.AGENTMAIL_AGENT_SECRET || '').trim();
this.agentName = String(env.AGENTMAIL_AGENT_NAME || '').trim();
}
/** 配置是否足以发请求 —— 缺密钥时要在第一次调用就明确报错,而不是收到 401 再猜。 */
checkConfig() {
const missing = [];
if (!this.agentKey && !this.agentSecret) missing.push('AGENTMAIL_AGENT_KEY');
if (!this.agentName) missing.push('AGENTMAIL_AGENT_NAME');
return missing;
}
authHeaders() {
const headers = { 'X-Agent-Name': this.agentName };
// 密钥优先;没有密钥时退回 secret与 pi/opencode/dsh 三桥同款兜底,
// 服务端两条路都认)。两者都没有时上面 checkConfig 已经拦住了。
if (this.agentKey) headers.Authorization = `Bearer ${this.agentKey}`;
else if (this.agentSecret) headers['X-Agent-Secret'] = this.agentSecret;
return headers;
}
async get(path) {
const res = await fetch(`${this.baseURL}/api/v1${path}`, { headers: this.authHeaders() });
return this.#parse(res, path);
}
async post(path, body) {
const res = await fetch(`${this.baseURL}/api/v1${path}`, {
method: 'POST',
headers: { ...this.authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(body ?? {})
});
return this.#parse(res, path);
}
async #parse(res, path) {
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = null;
}
if (!res.ok) {
// 服务端的错误信息是给人看的(中文、可操作),优先透传给模型
const message = (data && (data.error || data.message)) || text.slice(0, 300);
throw new GatewayError(res.status, message, path);
}
return data;
}
/**
* 上传附件。字段名必须是 `file`(服务端 `FormFile("file")`
* 返回的是 `{attachment:{...}}` 这种**嵌套**形状 —— 按顶层解会得到空 id
* 而那是静默的homeagent 踩过HTTP 200、附件数为 0
*/
async uploadFile(buf, filename) {
const form = new FormData();
form.append('file', new Blob([buf]), filename);
const res = await fetch(`${this.baseURL}/api/v1/attachments`, {
method: 'POST',
headers: this.authHeaders(),
body: form
});
const data = await this.#parse(res, '/attachments');
const attachment = data?.attachment;
if (!attachment?.attachment_id) {
throw new Error('上传响应里没有 attachment_id服务端响应结构可能已变更');
}
return attachment;
}
async downloadFile(attachmentID) {
const res = await fetch(`${this.baseURL}/api/v1/attachments/${attachmentID}`, {
headers: this.authHeaders()
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new GatewayError(res.status, body.slice(0, 200), `/attachments/${attachmentID}`);
}
return Buffer.from(await res.arrayBuffer());
}
}
/** 读本地文件并上传,返回附件的展示用信息。 */
export async function uploadLocalFile(client, filePath) {
const data = await readFile(filePath);
return client.uploadFile(data, basename(filePath));
}
/** 下载附件并落盘,必要时建父目录。 */
export async function downloadToFile(client, attachmentID, savePath) {
const buf = await client.downloadFile(attachmentID);
await mkdir(dirname(savePath), { recursive: true });
await writeFile(savePath, buf);
return buf.length;
}

View File

@ -0,0 +1,144 @@
/**
* 收件箱渲染与已读策略 —— 所有平台插件共用。
*
* 提到 lib/ 是因为这几条规则每一条都对应过一次真实的错误行为,而它们与
* 平台 SDK 无关:无论 opencode 的 zod 工具还是 DSH 的 defineTool
* 渲染出的文本与标记已读的时机都该一致。新接一个平台时直接复用这里。
*/
import { roleOf, replyAddressFor, participantsOfMail } from './addressing.js';
/** 人类可读的字节数,用于附件清单展示。 */
export function formatSize(n) {
if (typeof n !== 'number' || !Number.isFinite(n)) return '?';
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / 1024 / 1024).toFixed(1)} MB`;
}
/**
* 把一封邮件渲染成模型可读的文本块。
*
* @param {any} m `/mail/inbox` 返回的一封邮件
* @param {number} bodyLimit 正文截断长度
* @param {string} [selfName] 自己的 Agent 名。给了就能判定「我是收件人还是抄送方」
* 并给出参与方地址;不给则退化成旧行为(兼容未传该参数的调用方)。
* @returns {string}
*/
export function renderMail(m, bodyLimit = 200, selfName = '') {
const alias = m?.session_alias || '';
const lines = [
`[${m?.status ?? 'unknown'}] ${m?.from_name ?? 'unknown'}: ${m?.subject ?? '(无主题)'}`,
`邮件 ID: ${m?.mail_id ?? 'unknown'}`,
`会话: #${alias || '未命名'}`,
];
// 收件人必须显示。不显示的后果:被抄送方既不知道主收件人是谁,
// 也无法向对方转达或汇报 —— 线上那封联调邮件要求「由收件人汇报」,
// 抄送方却看不到收件人叫什么。
if (m?.to_name) {
let toLine = `收件人: ${m.to_name}`;
if (m?.to_workspace) toLine += `@${m.to_workspace}`;
lines.push(toLine);
}
// 抄送要显示:一封邮件为什么同时到了几个人手上,只有抄送能解释。
// 不显示的话模型会以为这是私下发给它一个人的,回信时漏掉其他参与方。
if (Array.isArray(m?.cc_list) && m.cc_list.length > 0) {
lines.push('抄送: ' + m.cc_list.map(c => c?.raw || c?.name || '?').join('、'));
}
// 自己的身份。抄送方与主收件人的职责不同,不区分的话两方都会
// 以为自己是负责人,或者都以为自己只是旁观者。
if (selfName) {
const role = roleOf(m, selfName);
if (role === 'to') lines.push('你的身份: 收件人(主办)');
else if (role === 'cc') lines.push('你的身份: 抄送方(配合)');
}
// **必须给出 attachment_id**:只说「有附件」模型就无从下载。
if (Array.isArray(m?.attachments) && m.attachments.length > 0) {
lines.push(
'附件: ' +
m.attachments
.map(a => `${a?.filename ?? '?'}${formatSize(a?.size_bytes)}, id=${a?.attachment_id ?? '?'}`)
.join('、')
);
lines.push('下载附件请用 download_attachment 工具。');
}
// 列表接口只给 body_preview省带宽单封接口才有 body。两者都兜住。
const body = m?.body_preview || m?.body || '';
lines.push(`内容: ${String(body).slice(0, bodyLimit)}`);
// 可投递地址放在最后,紧贴正文 —— 模型读完内容紧接着就要决定发给谁。
//
// 这一段是「精准发信」的关键:之前模型只能从抄送行里拄一个
// `opencode@/home.new` 拄过去,而 `.new` 是一次性的,回过去只会再建一条
// 平行会话。这里给的地址全部已经把 session 位换成真实别名。
if (selfName && alias) {
const parts = participantsOfMail(m, selfName, alias);
const others = parts.filter(p => !p.is_self && p.address);
if (others.length > 0) {
lines.push(
'可投递地址: ' +
others.map(p => `${p.address}${roleLabel(p.role)}`).join('、')
);
lines.push(`直接回信给发件人用 ${replyAddressFor(m, alias)},或传 reply_to=${m?.mail_id ?? ''}`);
}
}
return lines.join('\n');
}
/** 角色的中文标签。模型读到「抄送方」比读到 cc 更容易判对分工。 */
function roleLabel(role) {
switch (role) {
case 'from': return '发件人';
case 'to': return '收件人';
case 'cc': return '抄送方';
default: return role;
}
}
/**
* 渲染整个收件箱。
* @param {any[]} mails
* @param {number} bodyLimit
* @param {string} [selfName] 自己的 Agent 名,透传给 renderMail
* @returns {string}
*/
export function renderInbox(mails, bodyLimit = 200, selfName = '') {
const list = Array.isArray(mails) ? mails : [];
if (list.length === 0) return '收件箱为空。';
return list.map(m => renderMail(m, bodyLimit, selfName)).join('\n\n');
}
/**
* 判断本次读取该标记哪些邮件为已读。
*
* 两条规则:
*
* 1. **只标本次真正列出来的**,不是全部未读。`limit` 之外的还没看过,
* 一并标掉等于让它们凭空消失。
* 2. **`status=all` 时不标**。那是「回顾历史」的读法,把历史邮件标成已读
* 会让下一轮真正的新邮件混在里面认不出来。
*
* 不标的后果是每次拉收件箱都重复捞同一批,处理过的和新来的混在一起,
* 模型分不清哪封该回。
*
* @param {string|undefined} status 本次查询用的过滤条件
* @param {any[]} mails 本次返回的邮件
* @returns {string[]} 待标记的 mail_id空数组表示不需要标记
*/
export function idsToMarkRead(status, mails) {
if (status === 'all') return [];
const list = Array.isArray(mails) ? mails : [];
return list.map(m => m?.mail_id).filter(id => typeof id === 'string' && id);
}
/** 收件箱默认过滤条件。默认只看未读 —— 默认 all 会让模型每轮重读旧邮件。 */
export const DEFAULT_INBOX_STATUS = 'unread';
/** 收件箱默认返回条数。 */
export const DEFAULT_INBOX_LIMIT = 5;

View File

@ -0,0 +1,139 @@
/**
* MCPModel Context Protocol的 stdio 传输层与 JSON-RPC 分发。
*
* # 为什么手写而不引 `@modelcontextprotocol/sdk`
*
* 协议面很小:`initialize` / `notifications/initialized` / `tools/list` /
* `tools/call`。SDK 会带来一个 1MB 上下的打包产物与一条构建链,而本插件的
* 其余部分(网关客户端 + 工具)本就零运行时依赖 —— 与 pi/opencode/dsh 三个桥
* 的取向一致。手写还能让这一层成为**可单测的纯函数**,而不是只能靠连上宿主才验。
*
* # 分帧
*
* stdio 传输是**换行分隔的 JSON**一行一条消息UTF-8不是 Content-Length 分帧。
* 这一点是照官方插件实测确认的:它的打包产物里出现 `StdioServerTransport` 与
* `split("\n")`,而 `Content-Length` 出现 **0 次**。
*
* # 职责边界
*
* 本模块只做「消息进 → 消息出」,不碰 stdin/stdout也不认识具体工具 ——
* 于是它可以在测试里被穷举,而 I/O 只剩 server.mjs 里那一小段胶水。
*/
export const PROTOCOL_VERSION = '2024-11-05';
export const SERVER_NAME = 'agentmail';
export const SERVER_VERSION = '0.1.0';
/** JSON-RPC 错误码(只列我们真的会返回的)。 */
export const RPC_ERROR = {
PARSE: -32700,
INVALID_REQUEST: -32600,
METHOD_NOT_FOUND: -32601,
INVALID_PARAMS: -32602,
INTERNAL: -32603
};
const result = (id, value) => ({ jsonrpc: '2.0', id, result: value });
const failure = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
/**
* 处理一条已解析的 JSON-RPC 消息。
*
* @param {any} msg 解析后的消息
* @param {{tools: Array<{name:string, description:string, inputSchema:object}>,
* call: (name: string, args: object) => Promise<string>}} ctx
* @returns {Promise<object|null>} 要写回的消息notification无 id返回 null
*/
export async function handleMessage(msg, ctx) {
// 通知(没有 id不需要回复。`notifications/initialized` 就走这条 ——
// 若它也回一条,客户端会把响应与请求错配,后续调用全乱。
const isNotification = msg === null || typeof msg !== 'object' || !('id' in msg);
const id = isNotification ? null : msg.id;
if (typeof msg !== 'object' || msg === null || typeof msg.method !== 'string') {
return isNotification
? null
: failure(id, RPC_ERROR.INVALID_REQUEST, '请求缺少 method');
}
switch (msg.method) {
case 'initialize':
return isNotification
? null
: result(id, {
// 回显客户端给的协议版本:不认识的版本也回显,交由客户端决定是否降级 ——
// 自作主张改成我们的版本会让客户端以为协商成功而按新语义调用。
protocolVersion: msg.params?.protocolVersion || PROTOCOL_VERSION,
capabilities: { tools: { listChanged: false } },
serverInfo: { name: SERVER_NAME, version: SERVER_VERSION }
});
case 'notifications/initialized':
return null; // 纯通知
case 'ping':
return isNotification ? null : result(id, {});
case 'tools/list':
return isNotification
? null
: result(id, {
tools: ctx.tools.map(t => ({
name: t.name,
description: t.description,
inputSchema: t.inputSchema
}))
});
case 'tools/call': {
if (isNotification) return null;
const name = msg.params?.name;
const args = msg.params?.arguments ?? {};
if (typeof name !== 'string' || name === '') {
return failure(id, RPC_ERROR.INVALID_PARAMS, 'tools/call 缺少 name');
}
const known = ctx.tools.some(t => t.name === name);
if (!known) {
return failure(id, RPC_ERROR.INVALID_PARAMS, `没有名为 ${name} 的工具`);
}
try {
const text = await ctx.call(name, args);
return result(id, { content: [{ type: 'text', text: String(text ?? '') }] });
} catch (error) {
// 工具失败**不能**回 JSON-RPC error —— 那样模型看不到失败原因,
// 只会看到一次协议错误。MCP 的约定是 result + isError:true
// 于是错误文本进入对话,模型能据此改正(例如换一个 attachment_id
return result(id, {
content: [
{ type: 'text', text: `工具 ${name} 执行失败:${error?.message || error}` }
],
isError: true
});
}
}
default:
return isNotification
? null
: failure(id, RPC_ERROR.METHOD_NOT_FOUND, `不支持的方法 ${msg.method}`);
}
}
/**
* 把一行文本解析成消息并处理,返回要写回的行(或不返回)。
*
* 解析失败时**必须**回一条带 id=null 的解析错误JSON-RPC 规定),
* 否则客户端会一直等这一条的响应。
*/
export async function handleLine(line, ctx) {
const text = String(line ?? '').trim();
if (text === '') return null;
let msg;
try {
msg = JSON.parse(text);
} catch {
return JSON.stringify(failure(null, RPC_ERROR.PARSE, '不是合法的 JSON'));
}
const out = await handleMessage(msg, ctx);
return out === null ? null : JSON.stringify(out);
}

View File

@ -0,0 +1,393 @@
/**
* 暴露给 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';
/** 正文在列表里的截断长度(与另三端一致)。 */
const BODY_LIMIT = 200;
const str = (v, fallback = '') => (typeof v === 'string' ? v : fallback);
const obj = v => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
/**
* 构造工具集。
*
* @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',
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;
const { mails } = await client.get(
`/mail/inbox?status=${encodeURIComponent(status)}&limit=${limit}`
);
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',
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',
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',
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);
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',
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',
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',
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',
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',
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',
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',
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]));
}