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:
@ -20,6 +20,14 @@ import {
|
||||
DEFAULT_INBOX_STATUS,
|
||||
DEFAULT_INBOX_LIMIT,
|
||||
} from "./lib/inbox-format.js";
|
||||
import {
|
||||
renderNameSuggestions,
|
||||
renderPathSuggestions,
|
||||
renderSessionSuggestions,
|
||||
renderParticipants,
|
||||
renderContacts,
|
||||
renderThread,
|
||||
} from "./lib/discovery.js";
|
||||
import {
|
||||
explicitSends,
|
||||
noteExplicitSend,
|
||||
@ -242,7 +250,11 @@ const readInboxTool = {
|
||||
|
||||
// 渲染与已读策略放 lib/inbox-format.js:它们与平台 SDK 无关,
|
||||
// 各平台插件必须一致(见该文件里每条规则对应的错误行为)。
|
||||
const listed = renderInbox(data.mails);
|
||||
//
|
||||
// 传 AGENT_NAME 是为了让渲染能判定「我是收件人还是抄送方」并给出
|
||||
// 可投递地址 —— 不传的话模型只能从抄送行里抄一个 `.new`,而那是
|
||||
// 一次性的,回过去只会再建一条平行会话。
|
||||
const listed = renderInbox(data.mails, 200, AGENT_NAME);
|
||||
|
||||
const ids = idsToMarkRead(args.filter, data.mails);
|
||||
if (ids.length) {
|
||||
@ -317,6 +329,130 @@ const downloadAttachmentTool = {
|
||||
},
|
||||
};
|
||||
|
||||
// ─── 寻址发现工具(读 Agent 侧只读端点)───
|
||||
//
|
||||
// 在这一组之前,send_mail 的 to 是个只能靠记忆拼写的自由文本字段。人类侧
|
||||
// 从来不是这样:三段式输入框逐段查候选。Agent 只能猜,而猜错不报错 ——
|
||||
// 生产上 dsh 猜了 `opencode@/home`,投递成功,但那不是 opencode 的工作目录,
|
||||
// 那个错误路径静默变成了新会话的 workspace。
|
||||
//
|
||||
// 渲染逻辑在 lib/discovery.js(与平台 SDK 无关,三平台共用)。
|
||||
|
||||
const suggestAddressTool = {
|
||||
description:
|
||||
"查询可用的收件人地址,用于精准发信。分三段逐步查:不带参数给候选收件人名;" +
|
||||
"带 name 给它可用的工作目录;name+path 都带则给该目录下可续谈的会话别名与现成地址。" +
|
||||
"**发信前应先用它确认地址**,不要凭记忆拼写 —— 拼错不会报错,只会投到别的会话。",
|
||||
args: {
|
||||
name: z.string().optional().describe("收件人名;留空则列出所有候选收件人"),
|
||||
path: z.string().optional().describe("工作目录;与 name 同时给出才列会话"),
|
||||
},
|
||||
async execute(args) {
|
||||
const name = (args.name || "").trim();
|
||||
const path = (args.path || "").trim();
|
||||
const qs = new URLSearchParams();
|
||||
if (name) qs.set("name", name);
|
||||
if (path) qs.set("path", path);
|
||||
const data = await apiGet(`/agent/contacts/suggest?${qs.toString()}`);
|
||||
|
||||
// 按服务端回的 kind 分派,而不是按本地参数判断:省略 path 与传空串在
|
||||
// 服务端是同一个意思,但「哪一段该渲染成什么」只有服务端知道。
|
||||
switch (data?.kind) {
|
||||
case "name":
|
||||
return renderNameSuggestions(data.suggestions);
|
||||
case "path":
|
||||
return renderPathSuggestions(data.suggestions, name);
|
||||
default:
|
||||
return renderSessionSuggestions(data, name, path);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const listContactsTool = {
|
||||
description:
|
||||
"列出自己参与过的全部会话及各自的可投递地址、未读数、剩余往返预算。" +
|
||||
"用于回答「我还有什么没处理」以及「上次跟某人聊的那条线索地址是什么」。",
|
||||
args: {
|
||||
limit: z.number().optional().describe("最多列出多少条,默认 20"),
|
||||
},
|
||||
async execute(args) {
|
||||
const data = await apiGet("/agent/contacts");
|
||||
return renderContacts(data, args.limit || 20);
|
||||
},
|
||||
};
|
||||
|
||||
const sessionParticipantsTool = {
|
||||
description:
|
||||
"列出某条会话的全部参与方(发件人/收件人/抄送方)及各自的可投递地址," +
|
||||
"并标出谁还没回应。**要回给抄收方或向第三方转达时先用它拿地址**。",
|
||||
args: {
|
||||
session_id: z.string().describe("会话 ID(read_inbox 未直接给出时可从 read_thread 或新邮件通知取得)"),
|
||||
},
|
||||
async execute(args) {
|
||||
const data = await apiGet(`/agent/sessions/${args.session_id}/participants`);
|
||||
return renderParticipants(data);
|
||||
},
|
||||
};
|
||||
|
||||
const readThreadTool = {
|
||||
description:
|
||||
"查看一封邮件所在线索的完整往来(谁回了谁、谁还没回)。多方抄送协作时" +
|
||||
"用它确认别人已经说了什么,避免重复提问或重复汇报。",
|
||||
args: {
|
||||
mail_id: z.string().describe("线索中任一封邮件的 ID"),
|
||||
offset: z.number().optional().describe("分页偏移,续取时传上次返回的 next_offset"),
|
||||
},
|
||||
async execute(args) {
|
||||
const qs = args.offset ? `?offset=${args.offset}` : "";
|
||||
const data = await apiGet(`/agent/mail/${args.mail_id}/thread${qs}`);
|
||||
return renderThread(data, AGENT_NAME);
|
||||
},
|
||||
};
|
||||
|
||||
const readMailTool = {
|
||||
description:
|
||||
"读一封邮件的完整内容,含收件人、抄送清单、附件与每个参与方的可投递地址。" +
|
||||
"收件箱只给摘要;要回给抄收方就得先看清这封信发给了谁。",
|
||||
args: {
|
||||
mail_id: z.string().describe("邮件 ID"),
|
||||
},
|
||||
async execute(args) {
|
||||
const data = await apiGet(`/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 => c?.raw || c?.name).join("、")}`);
|
||||
}
|
||||
if (Array.isArray(m.attachments) && m.attachments.length) {
|
||||
lines.push(
|
||||
`附件: ${m.attachments
|
||||
.map(a => `${a.filename}(${formatSize(a.size_bytes)}, id=${a.attachment_id})`)
|
||||
.join("、")}`
|
||||
);
|
||||
}
|
||||
lines.push("", m.body || "(空正文)", "");
|
||||
// 参与方地址由服务端拼好(session 位已是真实别名,不是 .new)
|
||||
if (Array.isArray(data.participants) && data.participants.length) {
|
||||
lines.push(
|
||||
"可投递地址: " +
|
||||
data.participants
|
||||
.filter(p => p.address && p.name !== AGENT_NAME)
|
||||
.map(p => `${p.address}(${p.role})`)
|
||||
.join("、")
|
||||
);
|
||||
}
|
||||
if (data.reply_address) {
|
||||
lines.push(`回信给发件人用 ${data.reply_address},或传 reply_to=${m.mail_id}。`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
},
|
||||
};
|
||||
|
||||
// 平台原生权限询问 → 邮件。
|
||||
//
|
||||
// **不作为工具暴露给模型**:opencode 自己就有权限机制(permission.ask 钩子 /
|
||||
@ -1052,10 +1188,16 @@ export default async function mailBridge(input) {
|
||||
tool: {
|
||||
send_mail: sendMailTool,
|
||||
read_inbox: readInboxTool,
|
||||
read_mail: readMailTool,
|
||||
forward_mail: forwardMailTool,
|
||||
upload_attachment: uploadAttachmentTool,
|
||||
download_attachment: downloadAttachmentTool,
|
||||
connect_to_server: connectToServerTool,
|
||||
// 寻址发现:让模型选地址而不是拼地址
|
||||
suggest_address: suggestAddressTool,
|
||||
list_contacts: listContactsTool,
|
||||
session_participants: sessionParticipantsTool,
|
||||
read_thread: readThreadTool,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user