feat(zcode): 邮件驱动 —— 收到来信就自动开工,并把结论回信
第三步(补齐一等 Agent 的另一半):驱动进程订阅 SSE,按邮件起一轮 headless
ZCode,取最终文本回信。
## --mode 是必传的(不传等于关掉授权系统)
ZCode 的权限判定里 `mode === "yolo"` 一律 allow
("Yolo mode bypasses permission prompts"),而 `--prompt` 的默认 mode **就是 yolo**。
所以驱动不传 --mode 时:授权钩子根本不会触发,整个授权系统**静默消失** ——
不报错,只是没有任何询问,看起来一切正常。
档位映射(依据是 CLI 产物里的规则表,不是猜):
plan → --mode plan (mode.plan.nonReadOnly:非只读一律拒)
workspace → --mode build(mode.build.highRisk / sideEffect:Bash/Write/Edit → ask)
full → --mode yolo (刻意绕过)
buildRunArgs 收不到 mode 直接抛错;测试里有一条反向对照钉住「只有 full 能得到 yolo」,
含大写 FULL(共用库 normalizeMode 严格匹配,落回 default 而不是 yolo —— 好性质,也钉住)。
## 一轮怎么跑
node <zcode.cjs> --prompt <提示词> --output-format stream-json \
--cwd <工作目录> --mode <m> [--resume sess_xxx] --max-turns N
用 stream-json 而不是 --json:`--json` 全程无输出,一个卡住的回合与一个正在
干活的回合在外部完全一样,而邮件驱动的会话没有界面,日志是唯一能看见它的地方。
输出契约(逐条事件 + 末尾 {type:"result",sessionId,response})同样逆自 CLI 产物。
会话延续靠 --resume + 存回的 sess_…:丢了它模型每封信都从零开始。
## 回信策略(与另三桥同源)
- 人来信 → 自动把本轮最终文本回过去(relay:'summary' + relay_key 走免配额通道)
- Agent 来信 → **不**自动回(Agent 间必须自己 send_mail,否则两边把对方的
「已收到」当待办,无限客套)
- 一轮跑不起来 → **必回**失败信,且给出 ZCode 自己的成因(没登录/缺模型配置/
CLI 路径不对)。没有本地界面时,什么都不发等于「信发出去了,然后再无音讯」。
刻意不复用共用库那份 renderFailureReport:它的建议是「调整可用模型范围」,
对 ZCode 什么也解决不了。
- 模型这一轮自己发过信 → 让位。工具跑在 ZCode 派生的 MCP 服务器**进程**里,
与驱动内存不通,所以经 lib/explicit-sends.mjs 落盘对齐(不记的后果线上实测过:
收件箱里两封说同一件事的邮件,311 与 342 字节)。
## 两处健壮性(都是实现时自己发现的真问题)
- 超时必须**必然** settle:既不退也不报错的孩子会让 Promise 永不 settle,
而队列是串行的 → 那封信永远挂住、后面的信全都不再被处理。
现在 SIGTERM → SIGKILL → 无论如何收尾;定时器刻意不 unref
(unref 过的定时器让「没有其它句柄」的进程直接退出,收尾根本没机会跑)。
- 关停时终止在途回合:否则 systemd 杀掉驱动后那个 ZCode 还在跑工具,
而既没有驱动看着它、也没有本地界面看着它。
## 自报强制力只声明得出来的事
驱动启动时读自己的 hooks/hooks.json,确认 PermissionRequest 已注册才报 native,
否则报 advisory 并在日志里写明原因 —— 不替一个不存在的能力背书。
## 验证
- 单元 320/320(新增 90 项:turn-mode 8、zcode-run 17、driver 19、prompt 14 +
继承的共用测试;含反向对照)
- 邮件驱动端到端 7/7 × 3 次连跑稳定:桩 CLI 替掉 ZCode,真网关真邮件 ——
SSE 订阅、去重、工作目录、档位映射、参数拼装(--mode 必须对)、
stream-json 解析、回信、Agent 来信不回、CLI 失败必回失败信
- 授权桥端到端 5/5 × 3 次连跑稳定
- 共用模块四方同源(新纳入 catchup/relay-dedup/relay-policy/workspace,
反向验证:让 workspace.js 分叉会被抓住)
## 我自己写错并被测试抓出来的三处(值得记)
1. 验证脚本把人类发信写成了 /api/v1/mail/send(**Agent** 路由)→ 401。
报错「Missing Authorization: Bearer …」其实已经指明走错了路由表。
2. findReply 按「驱动验证(人)」这种片段找,第二次跑时命中了**上一轮遗留的回信**
→ 正文比对失败、后续参数核对变成「无法判定」。收件箱是跨轮次共享的持久状态,
必须按唯一 marker 定位(与之前「待决权限列表」那次是同一类错误)。
3. 停旧驱动只发 SIGTERM 不等退出 → 新旧两个驱动同时订阅 SSE,
同一封信被回两次,判据取到哪封取决于时序 → 时灵时不灵。改成等 exit 事件。
另:桩脚本用 process.exit 截断管道写入,导致 stderr 时有时无 —— 改用 exitCode。
This commit is contained in:
109
plugins/zcode-mail-bridge/src/prompt.mjs
Normal file
109
plugins/zcode-mail-bridge/src/prompt.mjs
Normal file
@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 由一封邮件构造驱动 ZCode 的提示词。
|
||||
*
|
||||
* 结构与 pi / dsh / opencode 三桥**同源**(复用 `lib/relay-policy.js` 的
|
||||
* `inboundHeadline` 与 `replyInstruction`):同一封邮件派到不同 Agent 上,
|
||||
* 模型该看到同样的交代。措辞一旦在某个平台走样,就会出现「同一封邮件
|
||||
* 在这个 Agent 上会回信、在那个 Agent 上装死」这种只在单平台复现的问题。
|
||||
*
|
||||
* 两个关键信号都来自服务端,不由插件猜:
|
||||
*
|
||||
* - `from_human`:决定「插件会不会替你回信」。猜错的代价不对称 ——
|
||||
* 把 Agent 的来信说成人的来信,会让模型以为有人会替它开口而什么都不做;
|
||||
* 反之只是多调一次 send_mail。
|
||||
* - `in_reply_to`:这封是回信还是新任务。模型分不清这两者时,会把对方一句
|
||||
* 「已收到」当成新待办再做一遍(Agent↔Agent 客套循环的真正成因)。
|
||||
*/
|
||||
|
||||
import { inboundHeadline, replyInstruction } from '../lib/relay-policy.js';
|
||||
|
||||
/** 去掉已有的 Re:/答复前缀,避免 `Re: Re: Re: …` 越滚越长。 */
|
||||
export function replySubject(subject) {
|
||||
const base = String(subject ?? '')
|
||||
.replace(/^\s*(re|答复|回复)\s*[::]\s*/gi, '')
|
||||
.trim();
|
||||
return base ? `Re: ${base}` : '本轮工作总结';
|
||||
}
|
||||
|
||||
/**
|
||||
* 一轮完全跑不起来时的回信正文。
|
||||
*
|
||||
* **必须发这封信**:模型一次都没跑起来 → 会话里没有任何产出 → 自动转发什么也
|
||||
* 不会发 → 发件人只会觉得「信发出去了,然后再无音讯」。邮件驱动的会话没有
|
||||
* 本地界面可以让人看见错误,回信是唯一的出口。
|
||||
*
|
||||
* 为什么不复用共用的 `renderFailureReport`:它的**建议**是平台特有的
|
||||
* (「调整可用模型范围」),而 ZCode 跑不起来的常见成因是没登录、缺模型配置、
|
||||
* CLI 本身报错 —— 照着那句建议去后台改模型范围,什么也解决不了。
|
||||
*
|
||||
* @param {{kind: string, error: string}[]} failures
|
||||
* @param {string} subject
|
||||
*/
|
||||
export function renderTurnFailure(failures, subject) {
|
||||
const list = Array.isArray(failures) ? failures : [];
|
||||
const lines = [
|
||||
`本次未能处理「${subject || '(无主题)'}」:ZCode 一轮都没能跑完。`,
|
||||
'',
|
||||
`已尝试 ${list.length} 次:`,
|
||||
''
|
||||
];
|
||||
list.forEach((f, i) => {
|
||||
lines.push(`${i + 1}. **${f?.kind || '失败'}**`);
|
||||
lines.push(` ${String(f?.error ?? '未知错误').replace(/\n/g, '\n ')}`);
|
||||
});
|
||||
lines.push('');
|
||||
lines.push('常见成因(按可能性):');
|
||||
lines.push('- **没有登录**:ZCode 的模型访问要 OAuth,未登录时 headless 会直接报缺模型配置;');
|
||||
lines.push(' 在服务器上跑 `node <zcode.cjs> login`,或直接开一次客户端扫码。');
|
||||
lines.push('- **模型配置缺失**:`~/.zcode/cli/config.json` 里没有显式 provider。');
|
||||
lines.push('- **CLI 路径不对**:`AGENTMAIL_ZCODE_CLI` 指向的 `zcode.cjs` 不存在或不可执行。');
|
||||
lines.push('- **工作目录不可写**:ZCode 要在里面建会话状态。');
|
||||
lines.push('');
|
||||
lines.push('修好后可以重新把原邮件发一次,或直接回复这封信。');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{agentName: string, data: any, kind?: string, reused?: boolean}} input
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildMailPrompt({ agentName, data, kind = 'mail', reused = false }) {
|
||||
// 权限结论不是「一封新邮件」,而是我们自己在等的那件事有了答复。
|
||||
// 当成普通邮件处理,模型会去 read_inbox 找一封其实已经不需要读的信。
|
||||
if (kind === 'permission') {
|
||||
return [
|
||||
`你之前发起的权限请求已有结论:${data?.decision ?? '(未给出)'}` +
|
||||
`(决策人:${data?.decided_by || '用户'})。`,
|
||||
'请据此继续后续工作。'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const fromHuman = data?.from_human === true;
|
||||
const lines = [
|
||||
inboundHeadline({
|
||||
inReplyTo: data?.in_reply_to,
|
||||
fromHuman,
|
||||
catchup: data?.catchup,
|
||||
reused
|
||||
}),
|
||||
'',
|
||||
`发件人:${data?.from_name || 'unknown'}`,
|
||||
`主题:${data?.subject || '(无主题)'}`,
|
||||
`邮件 ID:${data?.mail_id || 'unknown'}`
|
||||
];
|
||||
|
||||
if (data?.in_reply_to) lines.push(`回的是你那封:${data.in_reply_to}`);
|
||||
if (!reused) lines.push(`身份:你是 ${agentName}`);
|
||||
// 服务端算好的回信地址。带上它是因为模型**确实会**自己发信(抄送第三方、
|
||||
// 分多封交代不同的事)。让它自己拼三维地址的话,`.new` 会被拼进去,
|
||||
// 于是回信静默开出一条新会话,原线索里再无下文。
|
||||
if (data?.reply_address) lines.push(`回信地址:${data.reply_address}`);
|
||||
|
||||
lines.push(
|
||||
'',
|
||||
'请先调用 read_inbox 读取完整正文(附带附件清单,如有附件可用 download_attachment 取回),',
|
||||
'然后处理其中的请求。',
|
||||
...replyInstruction({ fromHuman, replyAddress: data?.reply_address })
|
||||
);
|
||||
return lines.join('\n');
|
||||
}
|
||||
Reference in New Issue
Block a user