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:
137
plugins/pi-mail-bridge/src/session-pool.mjs
Normal file
137
plugins/pi-mail-bridge/src/session-pool.mjs
Normal file
@ -0,0 +1,137 @@
|
||||
/**
|
||||
* pi 会话池 —— 每条 AgentMail 会话对应一条 pi 会话。
|
||||
*
|
||||
* 为什么桥必须自己持有 pi 会话(而不是写成一个 pi 扩展):
|
||||
* 扩展被加载进**一条已经存在的**会话里,cwd 由启动 pi 的人决定;而 B-3.1 要求
|
||||
* 每封邮件的 to_workspace 成为会话 cwd。扩展做不到「按邮件新开一条 cwd 不同的
|
||||
* 会话」,所以桥是一个常驻进程(C-7),用 SDK 的 createAgentSession 起会话。
|
||||
*
|
||||
* 每条会话一套 SettingsManager / ResourceLoader / SessionManager:它们都按 cwd
|
||||
* 解析项目级配置(.pi/、skills、prompts),共用一份会把 A 项目的配置带进 B 项目。
|
||||
*/
|
||||
|
||||
import { createAgentSession, SessionManager, SettingsManager, DefaultResourceLoader, getAgentDir }
|
||||
from '@earendil-works/pi-coding-agent';
|
||||
|
||||
/**
|
||||
* 起一条 pi 会话。
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.cwd 会话工作目录(已由 resolveWorkspaceCwd 校验过存在)
|
||||
* @param {any} opts.modelRuntime 共享的 ModelRuntime(建一次很贵,池外传进来)
|
||||
* @param {any} [opts.model] 指定模型;省略则用 settings 里的默认
|
||||
* @param {any[]} opts.customTools 邮件工具(send_mail / read_inbox / …)
|
||||
* @param {(pi: any) => void} [opts.extension] 内联扩展工厂,用来挂 tool_call 权限钩子
|
||||
* @returns {Promise<{session: any, sessionManager: any, diagnostics: any[]}>}
|
||||
*/
|
||||
export async function openSession({ cwd, modelRuntime, model, customTools, extension }) {
|
||||
const agentDir = getAgentDir();
|
||||
const settingsManager = SettingsManager.create(cwd, agentDir);
|
||||
|
||||
const resourceLoader = new DefaultResourceLoader({
|
||||
cwd,
|
||||
agentDir,
|
||||
settingsManager,
|
||||
// 关掉磁盘上的全局扩展。两个理由:
|
||||
// 1. 本机的 pi-a2a / pi-acp 在加载时 listen 固定端口(12010/12011),
|
||||
// 守护进程里加载会 EADDRINUSE,把整条会话拖死。
|
||||
// 2. 桥起的会话是给邮件用的,不该继承人类交互用的那套扩展(TUI 命令、
|
||||
// 快捷键、状态栏都没有意义)。
|
||||
// 邮件工具走 customTools,权限钩子走下面的 extensionFactories。
|
||||
noExtensions: true,
|
||||
extensionFactories: extension
|
||||
? [{ name: 'agentmail-bridge', factory: extension }]
|
||||
: [],
|
||||
});
|
||||
await resourceLoader.reload();
|
||||
|
||||
const sessionManager = SessionManager.create(cwd);
|
||||
const created = await createAgentSession({
|
||||
cwd,
|
||||
agentDir,
|
||||
modelRuntime,
|
||||
// model 为 undefined 时 SDK 用 settings 里的默认模型,正好对应
|
||||
// modelAttemptOrder 里那个 `undefined`(= 不指定、交给平台)。
|
||||
...(model ? { model } : {}),
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
resourceLoader,
|
||||
customTools,
|
||||
});
|
||||
|
||||
return {
|
||||
session: created.session,
|
||||
sessionManager,
|
||||
diagnostics: created.extensionsResult?.diagnostics ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 跑一轮并等到真正的结论(C-4 / D-3)。
|
||||
*
|
||||
* `session.prompt()` 的 promise 在**这一轮彻底结束**时才 resolve,所以不需要
|
||||
* 额外订阅 agent_end 去等。但它 resolve 了**不代表模型跑成功了** ——
|
||||
* 判定交给 classifyTurnOutcome(三条互不重叠的失败信号,见那里的注释)。
|
||||
*
|
||||
* 60 秒超时算成功(与另两个插件同一取舍):长任务很正常,把它判成失败会
|
||||
* 换模型重跑一遍,等于同一封邮件跑两次。超时只是「不再等着上报结论」,
|
||||
* 会话仍在跑,轮次结束后 agent_end 会照常触发自动转发。
|
||||
*
|
||||
* 会话正在跑时走排队(返回 queued),**不能**在那种情况下判结论:
|
||||
* prompt 排完队就 resolve,此时 session.messages 里最后一条是**上一轮**的,
|
||||
* 拿它判定会把上一轮的成败当成这一轮的。
|
||||
*
|
||||
* @param {any} session
|
||||
* @param {string} promptText
|
||||
* @param {number} timeoutMs
|
||||
* @returns {Promise<{ok: boolean, error: string, aborted: boolean, timedOut: boolean, queued: boolean}>}
|
||||
*/
|
||||
export async function runTurn(session, promptText, timeoutMs = 60_000) {
|
||||
const { classifyTurnOutcome } = await import('./turn.mjs');
|
||||
|
||||
// 排队分支:模型还在说话时又来一封邮件。
|
||||
//
|
||||
// streamingBehavior 必选,缺了 prompt 直接抛
|
||||
// "Agent is already processing. Specify streamingBehavior…"。
|
||||
// 取 followUp 而不是 steer:steer 会把当前这一轮打断,
|
||||
// 而当前这一轮正在处理**上一封邮件** —— 那封邮件的发件人也在等回信。
|
||||
if (session.isStreaming) {
|
||||
await session.prompt(promptText, { streamingBehavior: 'followUp' });
|
||||
return { ok: true, error: '', aborted: false, timedOut: false, queued: true };
|
||||
}
|
||||
|
||||
let timer = null;
|
||||
const timeout = new Promise((resolve) => {
|
||||
timer = setTimeout(
|
||||
() => resolve({ ok: true, error: '', aborted: false, timedOut: true, queued: false }),
|
||||
timeoutMs,
|
||||
);
|
||||
});
|
||||
|
||||
const run = session.prompt(promptText)
|
||||
.then(() => ({ ...classifyTurnOutcome({ messages: session.messages }), timedOut: false, queued: false }))
|
||||
.catch((e) => ({ ...classifyTurnOutcome({ error: e }), timedOut: false, queued: false }));
|
||||
|
||||
try {
|
||||
return await Promise.race([run, timeout]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 续谈:往一条已经存在的会话里追加一轮。
|
||||
*
|
||||
* 这就是 `runTurn` —— 不需要第二个函数。
|
||||
*
|
||||
* **不能**用 `session.followUp()`:那个方法只往 followUpQueue 里塞消息,
|
||||
* 队列**只在运行中的轮次末尾**被 drain(pi-agent-core/agent.js 的 run 循环,
|
||||
* 以及 `continue()`)。会话空闲时(上一轮早已结束)塞进去的消息永远没人取,
|
||||
* 于是这封邮件既没有回信也没有报错 —— 实测踩过:日志打了「续谈」,
|
||||
* 收件箱里只有来信没有回复。
|
||||
*
|
||||
* `runTurn` 按 `isStreaming` 分流,两种状态都正确:
|
||||
* - 空闲 → `prompt()` 直接起一轮
|
||||
* - 正在跑 → `prompt(text, {streamingBehavior:'followUp'})` 排到当轮之后
|
||||
*/
|
||||
export { runTurn as followUpTurn };
|
||||
Reference in New Issue
Block a user