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:
@ -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 {}
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user