Files
MailUI4Agents/plugins/pi-mail-bridge/src/turn.mjs
JianFeeeee e6fd2fafdc feat: agent 邮件寻址能力全面补齐 + .new 别名替换
## 别名替换(让 .new 邮件可寻址)

repo/autoalias.go: AutoAliasFor + EnsureSessionAlias
- .new 建完会话立刻给别名(形如 dsh-重构导入路径)
- 名字与主题都要:只用主题跨 Agent 撞名,只用名字看不出聊什么
- sanitizeAliasPart 只留 unicode.IsLetter/IsDigit,其余折 -
- 撞名追加 -2/-3,全占用退 session-<uuid前8位>
- 不复用 SyncSessionAlias:那个假定已存在且跳过 manual
- 条件写入 WHERE alias IS NULL OR '',并发安全
- resolveTarget 的 .new 与默认会话两条路径都调

notifyRecipients 加三个字段(每个收件方拿到自己那个地址的版本):
- session_alias / reply_address / self_address
- 别名为空时退回省略 session 位,绝不写 new

FormatAddress(name,path,session) 空 path 也必须留 @ 与 .

## Agent 侧寻址发现(五个只读端点)

handler/agent_discovery.go:
- /agent/contacts + /agent/contacts/suggest(三段式补全)
- /agent/mail/{id} + /agent/mail/{id}/thread
- /agent/sessions/{id}/participants
- 不复用人类路由:scope 不同、审计需求不同
- 一律只读:归档/改名/权限决策仍只有人能做

repo/participants.go: SessionParticipants 逐封扫 from/to/cc
- Roles 用集合、MailCount 只数发信(0=还没开口的人)
- 发件人 path 不取 from_workspace(那列存的是 Agent 名)

repo.SuggestPaths 重写:mails.to_workspace(按 MAX(created_at) 倒序)
+ agents.workspaces 并集。原只读 workspaces,官方插件传 [] 永远空

## 共用模块(三插件逐字节相同)

lib/addressing.js: formatAddress/roleOf/replyAddressFor/selfAddressFor/participantsOfMail
lib/discovery.js: renderNameSuggestions/renderPathSuggestions/renderSessionSuggestions/
                  renderParticipants/renderContacts/renderThread

lib/inbox-format.js: renderMail 新增收件人/身份/可投递地址三段
  - selfName 参数(兼容旧调用不传的情况)

check-shared-libs.sh 纳入 addressing + discovery

## 插件侧

opencode: suggest_address + list_contacts + session_participants + read_thread + read_mail
dsh: 同上 + forward_mail(此前只有 opencode 有)+ upload_attachment 改真 multipart
pi: 同上(createMailTools 加 agentName 参数)

dsh: ctx.agents.create id collision 改为 readSession 探测后 resume
dsh: 关键路径日志改 console.error(ctx.logger 不进 journalctl)

## 测试

repo: autoalias_test.go 11 + participants_test.go 7 = 18 例
plugins: addressing.test 17 + discovery.test 23 + inbox-format.test 31 = 71 例
go test ./... + npm test(opencode 155 + dsh 173 + pi 199)全绿
端到端验证:admin 发 dsh@....new 抄送 opencode@....new
  → dsh 用 session_participants 取到地址 → send_mail 给 opencode
  → 地址取自工具返回值(.crisp-planet),未手工拼写
2026-09-03 12:09:12 +08:00

188 lines
7.9 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.

/**
* pi 侧的纯逻辑:提示词、轮次结论判定、消息文本提取、回信主题。
*
* 单独一个文件而不是塞进 index.mjs:这几件事每一件都对应过一次真实的错误行为,
* 而它们都不需要 pi SDK —— 因此可以直接用 node --test 钉住,不必起模型。
*
* 与 lib/ 的区别:lib/ 下的文件三个平台**逐字节相同**(deploy/check-shared-libs.sh
* 校验),这里的东西是 pi 专属的(消息形状、stopReason 语义),不参与那个约束。
*/
/** 去掉已有的 Re: 前缀,避免 Re: Re: Re: 叠加。 */
export function stripRe(subject) {
return String(subject ?? '').replace(/^(\s*Re:\s*)+/i, '');
}
/** 自动转发时的回信主题。 */
export function replySubject(subject) {
const base = stripRe(subject).trim();
return base ? `Re: ${base}` : '本轮工作总结';
}
/**
* 取最后一条 assistant 消息里的纯文本。
*
* pi 的消息形状:`{ role, content: [{ type: 'text'|'thinking'|'toolCall', ... }] }`。
*
* **只取 `type === 'text'`**(B-5.1):thinking 块是思考过程,转进邮件对收件人
* 没有意义,而且经常包含「我先假设…」这类会被误读为结论的话。
*
* 从后往前找第一条**有文本**的 assistant 消息,而不是「最后一条 assistant 消息」:
* 一轮的收尾常常是纯工具调用消息(content 里只有 toolCall),
* 取到它会得到空字符串,于是 B-5.4 判成「无话可说」而漏掉真正的结论。
*
* @param {any[]} messages `session.messages` 或 `agent_end` 事件里的 messages
* @returns {string} 纯文本,找不到时为空串
*/
export function lastAssistantText(messages) {
const list = Array.isArray(messages) ? messages : [];
for (let i = list.length - 1; i >= 0; i--) {
const m = list[i];
if (m?.role !== 'assistant') continue;
const blocks = Array.isArray(m.content) ? m.content : [];
const text = blocks
.filter((b) => b?.type === 'text' && typeof b.text === 'string')
.map((b) => b.text)
.join('\n')
.trim();
if (text) return text;
}
return '';
}
/**
* 判定这一轮到底跑起来了没有(C-4 / D-3)。
*
* 「submit 返回了」不等于「模型跑了」—— 这是两次适配都踩过的坑(契约 9.2)。
* pi 侧有三条互不重叠的失败信号,必须全查:
*
* 1. `prompt()` 直接 reject。凭证缺失就是这条:实测无 API key 的 provider
* 抛 `No API key found for amazon-bedrock.`,一个事件都不发。
* 2. 最后一条 assistant 消息 `stopReason === 'error'`,原因在 `errorMessage`。
* 模型请求发出去了但上游报错走这条。
* 3. 一条 assistant 消息都没有。既没抛也没报错却什么都没产出,
* 当成功处理会让 B-5 转发一个空字符串回去 —— 发件人收到一封空邮件。
*
* `stopReason: 'aborted'` **算失败**但要区别对待:那是有人主动打断
* (Esc / dispose),不是模型故障,因此不该触发换模型重试。
*
* @param {{error?: any, messages?: any[]}} input
* @returns {{ok: boolean, error: string, aborted: boolean}}
*/
export function classifyTurnOutcome({ error, messages } = {}) {
if (error) {
return { ok: false, error: describeError(error), aborted: false };
}
const list = Array.isArray(messages) ? messages : [];
let lastAssistant = null;
for (let i = list.length - 1; i >= 0; i--) {
if (list[i]?.role === 'assistant') { lastAssistant = list[i]; break; }
}
if (!lastAssistant) {
return { ok: false, error: '模型没有产出任何回复(一条 assistant 消息都没有)', aborted: false };
}
const stop = lastAssistant.stopReason;
if (stop === 'error') {
return {
ok: false,
error: describeError(lastAssistant.errorMessage) || '模型报错但未给出原因',
aborted: false,
};
}
if (stop === 'aborted') {
return { ok: false, error: '本轮被中断(aborted)', aborted: true };
}
// 'stop' 正常收尾;'length' 是被 max tokens 截断 —— 内容不完整但**是模型的产出**,
// 判成失败会让一封「说了一半」的回信变成「换个模型重试」,那更糟。
// 'toolUse' 出现在这里说明轮次在等工具,正常流程下 agent_end 时不会是它。
return { ok: true, error: '', aborted: false };
}
/** 把各种形态的错误拼成一行可读文本。 */
export function describeError(err) {
if (!err) return '';
if (typeof err === 'string') return err.split('\n')[0].trim();
const parts = [err.code, err.message ?? String(err)].filter(Boolean);
return parts.join(': ').split('\n')[0].trim() || '未知错误';
}
/**
* 投递一封邮件时给模型的提示词。
*
* 三条硬要求(B-3.4 / B-3.5):
* - 写明「回信由插件自动发」。不说的话模型会自己调 send_mail,
* 而插件在轮次结束时也会转发一次 —— 同一件事两封邮件(生产里真实发生过)。
* - 带上 mail_id,让模型能自己定位这一封。
* - 让它先调 read_inbox:事件里只有主题,正文和附件清单都在收件箱里。
*
* @param {{agentName: string, data: any, kind: string, reused: boolean}} input
* @returns {string}
*/
export function buildMailPrompt({ agentName, data, kind, reused }) {
if (kind === 'permission') {
return [
`你之前发起的权限请求已有结论:${data?.decision ?? '(未给出)'}` +
`(决策人:${data?.decided_by || '用户'})。`,
`请据此继续后续工作。`,
].join('\n');
}
const head = reused
? '本会话收到一封新邮件(AgentMail 续谈)。'
: '你收到一封新邮件(AgentMail)。';
const lines = [
head,
'',
`发件人:${data?.from_name || 'unknown'}`,
`主题:${data?.subject || '(无主题)'}`,
`邮件 ID:${data?.mail_id || 'unknown'}`,
];
if (!reused) lines.push(`身份:你是 ${agentName}`);
// 服务端算好的回信地址(`new_mail` 的 reply_address)。带上它是因为模型
// **确实会**自己发信 —— 尤其是要抄送第三方、或分多封交代不同的事时。
// 让它自己拼三维地址的话,`.new` 会被拼进去,于是回信静默开出一条新会话,
// 原来的线索里再无下文。
if (data?.reply_address) {
lines.push(`回信地址:${data.reply_address}(如需自己发信,用这个地址)`);
}
if (data?.catchup) {
// 补投的邮件要说明,否则模型会以为这是刚到的、按「立即响应」的语气回
lines.push('说明:这是插件离线期间积压的邮件,现在补投给你。');
}
lines.push(
'',
'请先调用 read_inbox 读取完整正文(附带附件清单,如有附件可用 download_attachment 取回),',
'然后处理其中的请求。',
'回信不用你自己发:把这一轮做完、把结论说出来就行,插件会把你最后那段话作为回信发出去。',
);
return lines.join('\n');
}
/**
* 自动转发的幂等键(W-6 / B-5.2)。
*
* 用 pi 侧的会话 id + 会话树叶子条目 id:两者都由 pi 生成且落盘,
* 插件重启后重放同一轮也会得到同一个键。用「消息条数」之类的派生量不行 ——
* 压缩(compaction)会改变条数,于是同一轮结论换了个键,被当成新消息再转一次。
*
* @param {string} piSessionId
* @param {string} leafId
* @returns {string}
*/
export function relayKeyFor(piSessionId, leafId) {
return `${piSessionId || 'unknown'}:${leafId || 'noleaf'}`;
}
/**
* pi 会话文件名里的 cwd 编码(`/home/x` → `--home-x--`)。
*
* 只用于日志与排查提示,不参与任何决策 —— 真正的路径一律用 SDK 给的
* `session.sessionFile`。自己拼路径去读会话文件是错的:编码规则属于 pi。
*
* @param {string} cwd
* @returns {string}
*/
export function sessionDirLabel(cwd) {
return `--${String(cwd ?? '').replace(/\//g, '-')}--`;
}