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),未手工拼写
This commit is contained in:
2026-09-03 12:09:12 +08:00
parent 22ddb1b89c
commit e6fd2fafdc
81 changed files with 11355 additions and 122 deletions

View File

@ -42,6 +42,14 @@ import {
DEFAULT_INBOX_STATUS,
DEFAULT_INBOX_LIMIT,
} from '../lib/inbox-format.js';
import {
renderNameSuggestions,
renderPathSuggestions,
renderSessionSuggestions,
renderParticipants,
renderContacts,
renderThread,
} from '../lib/discovery.js';
// ─── 凭证管理 ───
@ -294,11 +302,11 @@ export function apply(ctx: any, config: PluginConfig): void {
try {
await deliverMail(ev, 'mail');
} catch (e: any) {
ctx.logger.error(`[dsh-mail-bridge] 补投 ${ev.mail_id} 失败: ${e?.message || e}`);
console.error(`[dsh-mail-bridge] 补投 ${ev.mail_id} 失败: ${e?.message || e}`);
}
}
} catch (e: any) {
ctx.logger.error(`[dsh-mail-bridge] 补投失败: ${e?.message || e}`);
console.error(`[dsh-mail-bridge] 补投失败: ${e?.message || e}`);
}
}
@ -440,6 +448,82 @@ export function apply(ctx: any, config: PluginConfig): void {
});
}
// ─── 建会话(磁盘上已有则 resume───
/**
* 问持久化层:磁盘上是否已经有这条会话?
*
* `sessionMap` 是纯内存的,插件重启后为空,于是同一封邮件的续谈会走
* 「新开会话」那条路,用回同一个 `mail-<session_id>` —— 而那个 id 上一次
* 已经落过盘。只能问持久化层,因为这是重启后唯一还存在的事实来源。
*
* 读不到就当作不存在:`readSession` 在会话不存在、日志不可读、replay 校验
* 不过时都会抛。三种情形里只有第一种适合 create但后两种 resume 也一样
* 救不回来 —— 那就让 create 去报它自己的错。
*/
async function persistedCwd(sessionId: string): Promise<string | undefined> {
const q: any = (ctx as any).get?.('sessionQuery');
if (!q?.readSession) return undefined;
try {
const snap = await q.readSession(sessionId);
return snap?.header?.cwd ?? '';
} catch {
return undefined;
}
}
/**
* 启一个 agent磁盘上没有这个 id 就 create有就 resume。
*
* # 为何必须先探测,不能靠 try/catch
*
* id 冲突不是 `create` 报的:持久化是在**轮次进行中** flush 的,所以
* `create` 会正常返回,错误到 `turn/end` 才以 `reason.kind === 'error'`
* 冲出来(实测:`UNKNOWN: session "..." already has a persisted log on disk`)。
* 把修法写成 catch 里改 resume 完全不会生效 —— 这与「模型失败不是同步抛出的」
* 是同一类陷阱,只是上了一层。
*
* # 为何 resume 而不是换一个新 id
*
* 换 id 等于把之前的往来上下文丢掉,模型会重新问一遍已经问过的问题。
* resume 把磁盘上那条会话装回来接着谈,这同时修掉了一个已知取舍:
* 插件重启后续谈的邮件不再另开一条平台会话。
*
* resume 不接受 `meta`cwd 取自持久化的 header。这正是想要的 —— 上一次在哪个
* 目录,就继续在那儿;传一个不同的 cwd 只会得到
* `is already persisted at a different cwd` 而不是“改目录”。
*/
async function startAgent(
sessionId: string, cwd: string, route: any,
): Promise<{ handle: any; resumed: boolean }> {
// route 为 undefined 表示不指定模型,交给平台自己选
const agentOptions = route ? { provider: route.provider, model: route.model } : {};
const onDisk = await persistedCwd(sessionId);
if (onDisk !== undefined) {
// 用 console.error 而不是 ctx.logger.info后者不进 journalctl实测
// 而这条是排查「邮件投不进去」时唯一能看到的线索。
console.error(`[dsh-mail-bridge] 会话 ${sessionId} 已在磁盘上cwd=${onDisk || '未记录'}),改为 resume 续谈`);
const handle = await ctx.agents.resume({
resumeSessionId: sessionId as any,
agentOptions,
setup: undefined,
});
return { handle, resumed: true };
}
const handle = await ctx.agents.create({
sessionId,
meta: { cwd },
agentOptions,
// setup 留空DSH 的 base bundle 已经注册了 agent-loop、llm、tools 等服务。
// 模型路由通过 agentOptions 传入即可 —— 挂载 preset 或
// installModelSelection 反而会让 turn 崩溃(实测)。
setup: undefined,
});
return { handle, resumed: false };
}
// ─── 投递邮件到 DSH 会话 ───
async function deliverMail(data: any, kind: string): Promise<{ sessionID: string; reused: boolean }> {
@ -517,20 +601,13 @@ export function apply(ctx: any, config: PluginConfig): void {
let handle: any;
try {
handle = await ctx.agents.create({
sessionId: attemptSessionId,
meta: { cwd },
// route 为 undefined 表示不指定模型,交给平台自己选
agentOptions: route ? { provider: route.provider, model: route.model } : {},
// setup 留空DSH 的 base bundle 已经注册了 agent-loop、llm、tools 等服务。
// 模型路由通过 agentOptions 传入即可 —— 挂载 preset 或
// installModelSelection 反而会让 turn 崩溃(实测)。
setup: undefined,
});
const started = await startAgent(attemptSessionId, cwd, route);
handle = started.handle;
} catch (e: any) {
// create 本身很少失败(它不校验模型),但会话 id 冲突之类仍会抛
// create/resume 本身很少失败(create 不校验模型),
// 但 cwd 不符、日志 replay 不过之类仍会抛
failures.push({ ...(route ?? {}), error: e?.message || String(e) });
ctx.logger.error(`[dsh-mail-bridge] 建会话失败 ${label}: ${e?.message || e}`);
console.error(`[dsh-mail-bridge] 建会话失败 ${label}: ${e?.message || e}`);
continue;
}
@ -553,13 +630,13 @@ export function apply(ctx: any, config: PluginConfig): void {
if (outcome.ok) {
if (failures.length > 0) {
ctx.logger.info(`[dsh-mail-bridge] ${label} 成功(前 ${failures.length} 个失败)`);
console.error(`[dsh-mail-bridge] ${label} 成功(前 ${failures.length} 个失败)`);
}
return { sessionID: attemptSessionId, reused: false };
}
failures.push({ ...(route ?? {}), error: outcome.error });
ctx.logger.error(`[dsh-mail-bridge] 模型 ${label} 失败: ${outcome.error}`);
console.error(`[dsh-mail-bridge] 模型 ${label} 失败: ${outcome.error}`);
// 拆掉这一路的 agent 与映射,否则它会占着会话 id
// 而 agent/status 还会为这个死会话触发一次自动转发
reverseMap.delete(attemptSessionId);
@ -693,7 +770,9 @@ export function apply(ctx: any, config: PluginConfig): void {
// 渲染与已读策略放 lib/inbox-format.js它们与平台 SDK 无关,
// 各平台插件必须一致(见该文件里每条规则对应的错误行为)。
const listed = renderInbox(mails);
//
// 传 AGENT_NAME 才能判定「我是收件人还是抄送方」并给出可投递地址。
const listed = renderInbox(mails, 200, AGENT_NAME);
// 读过就标掉,否则每次拉收件箱都重复捞同一批,
// 处理过的和新来的混在一起,模型分不清哪封该回。
@ -722,10 +801,21 @@ export function apply(ctx: any, config: PluginConfig): void {
async execute(args: any): Promise<string> {
const data = await readFile(args.file_path);
const filename = args.file_path.split('/').pop() || 'file';
// **必须发真正的 multipart。**
//
// 早先这里发的是 `Content-Type: application/octet-stream` 加一个
// `X-Filename` 头,而服务端走 `ParseMultipartForm` + `FormFile("file")` ——
// 于是 **这个工具从来没成功过一次**,每次都回「解析 multipart 失败」。
// 模型甚至把它当成了文件存在性探针(存在→报 multipart 错、
// 不存在→ENOENT那是对症状的准确利用但不是它应该做的事。
//
// 不设 Content-Type交给 FormData 自己带 boundary手写的一定对不上。
const form = new FormData();
form.append('file', new Blob([data]), filename);
const res = await fetch(`${client.baseURL}/api/v1/attachments`, {
method: 'POST',
headers: { ...client.authHeaders(), 'Content-Type': 'application/octet-stream', 'X-Filename': filename },
body: data,
headers: client.authHeaders(),
body: form,
});
const json = await res.json() as any;
if (!res.ok) throw new Error(json?.error || `HTTP ${res.status}`);
@ -757,8 +847,185 @@ export function apply(ctx: any, config: PluginConfig): void {
},
}));
// ─── 寻址发现工具(读 Agent 侧只读端点)───
//
// 在这一组之前send_mail 的 to 是个只能靠记忆拼写的自由文本字段,
// 而拼错不报错:生产上本插件猜了 `opencode@/home`,投递成功,
// 但那不是 opencode 的工作目录,静默变成了新会话的 workspace。
ctx.tools.register(defineTool({
name: 'suggest_address',
description:
'查询可用的收件人地址,用于精准发信。不带参数给候选收件人名;带 name 给它可用的工作目录;' +
'name+path 都带则给该目录下可续谈的会话与现成地址。**发信前应先用它确认地址**' +
'不要凭记忆拼写 —— 拼错不会报错,只会投到别的会话。',
parameters: {
name: { type: 'string', description: '收件人名;留空则列出所有候选收件人' },
path: { type: 'string', description: '工作目录;与 name 同时给出才列会话' },
},
output: {
schema: { type: 'string' },
render: (_args: any, value: string) => [{ type: 'text', text: value }],
},
async execute(args: any): Promise<string> {
const name = String(args.name || '').trim();
const path = String(args.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 renderNameSuggestions(data.suggestions);
case 'path': return renderPathSuggestions(data.suggestions, name);
default: return renderSessionSuggestions(data, name, path);
}
},
}));
ctx.tools.register(defineTool({
name: 'list_contacts',
description:
'列出自己参与过的全部会话及各自的可投递地址、未读数、剩余往返预算。' +
'用于回答「我还有什么没处理」与「上次跟某人聊的那条线索地址是什么」。',
parameters: {
limit: { type: 'number', description: '最多列出多少条,默认 20' },
},
output: {
schema: { type: 'string' },
render: (_args: any, value: string) => [{ type: 'text', text: value }],
},
async execute(args: any): Promise<string> {
const data = await client.get('/agent/contacts');
return renderContacts(data, args.limit || 20);
},
}));
ctx.tools.register(defineTool({
name: 'session_participants',
description:
'列出某条会话的全部参与方(发件人/收件人/抄送方)及各自的可投递地址,' +
'并标出谁还没回应。**要回给抄收方或向第三方转达时先用它拿地址**。',
parameters: {
session_id: { type: 'string', required: true, description: '会话 ID' },
},
output: {
schema: { type: 'string' },
render: (_args: any, value: string) => [{ type: 'text', text: value }],
},
async execute(args: any): Promise<string> {
const data = await client.get(`/agent/sessions/${args.session_id}/participants`);
return renderParticipants(data);
},
}));
ctx.tools.register(defineTool({
name: 'read_thread',
description:
'查看一封邮件所在线索的完整往来(谁回了谁、谁还没回)。多方抄送协作时' +
'用它确认别人已经说了什么,避免重复提问或重复汇报。',
parameters: {
mail_id: { type: 'string', required: true, description: '线索中任一封邮件的 ID' },
offset: { type: 'number', description: '分页偏移,续取时传上次返回的 next_offset' },
},
output: {
schema: { type: 'string' },
render: (_args: any, value: string) => [{ type: 'text', text: value }],
},
async execute(args: any): Promise<string> {
const qs = args.offset ? `?offset=${args.offset}` : '';
const data = await client.get(`/agent/mail/${args.mail_id}/thread${qs}`);
return renderThread(data, AGENT_NAME);
},
}));
ctx.tools.register(defineTool({
name: 'read_mail',
description:
'读一封邮件的完整内容,含收件人、抄送清单、附件与每个参与方的可投递地址。' +
'收件箱只给摘要;要回给抄收方就得先看清这封信发给了谁。',
parameters: {
mail_id: { type: 'string', required: true, description: '邮件 ID' },
},
output: {
schema: { type: 'string' },
render: (_args: any, value: string) => [{ type: 'text', text: value }],
},
async execute(args: any): Promise<string> {
const data = await client.get(`/agent/mail/${args.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: any) => c?.raw || c?.name).join('、')}`);
}
if (Array.isArray(m.attachments) && m.attachments.length) {
lines.push(`附件: ${m.attachments
.map((a: any) => `${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: any) => p.address && p.name !== AGENT_NAME)
.map((p: any) => `${p.address}${p.role}`)
.join('、'));
}
if (data.reply_address) {
lines.push(`回信给发件人用 ${data.reply_address},或传 reply_to=${m.mail_id}`);
}
return lines.join('\n');
},
}));
// forward_mail —— 转发给新收件人。
//
// 之前 DSH 侧缺这个工具opencode 侧一直有),于是本平台上「把这封信
// 转给某人」只能退化成 send_mail 重抄一遍正文 —— 丢掉附件、丢掉
// parent_mail_id对话树上也看不出这条新线索从何而来。
ctx.tools.register(defineTool({
name: 'forward_mail',
description:
'转发一封邮件给新的收件人(自动引用原文与附件)。与回复不同:回复落回原会话,' +
'转发按目标地址另行定位会话(它是一条新线索)。只能转发自己参与过的邮件。',
parameters: {
mail_id: { type: 'string', required: true, description: '要转发的邮件 ID' },
to: { type: 'string', required: true, description: '新收件人的三维地址(先用 suggest_address 确认)' },
comment: { type: 'string', description: '转发说明,置于引用原文之前' },
cc: { type: 'string', description: '抄送,逗号分隔多个三维地址' },
subject: { type: 'string', description: '自定义主题;留空则自动加 Fwd: 前缀' },
session_alias: { type: 'string', description: '仅当目标地址以 .new 结尾时生效:给新会话命名' },
},
output: {
schema: { type: 'string' },
render: (_args: any, value: string) => [{ type: 'text', text: value }],
},
async execute(args: any, toolCtx: any): Promise<string> {
const result = await client.post(`/mail/${args.mail_id}/forward`, {
to: args.to,
comment: args.comment || '',
cc: args.cc || '',
subject: args.subject || '',
session_alias: args.session_alias || '',
});
// 转发也是一次「模型亲手发信」,要计入 explicitSends
// 否则本轮结束时自动转发会再把同一段话发一遍。
noteExplicitSend(toolCtx?.sessionID, args.to, '');
return `已转发。新 Mail ID: ${result.mail_id}Session: ${result.session_id}`;
},
}));
return () => {
for (const n of ['send_mail', 'read_inbox', 'upload_attachment', 'download_attachment']) {
for (const n of [
'send_mail', 'read_inbox', 'read_mail', 'forward_mail',
'upload_attachment', 'download_attachment',
'suggest_address', 'list_contacts', 'session_participants', 'read_thread',
]) {
try { ctx.tools.unregister(n); } catch {}
}
};