根因:startAgent 的 resume 分支 setup: undefined,注释说 「resume 从磁盘恢复,工具已在」—— 实际上工具是通过 setup 回调 presets.mount 注册的,resume 不传 setup 就没有任何文件工具。 presets.mount 修复之前创建的旧会话(setup:undefined 时代)从此 没有 read/write/edit/bash/glob/grep,用户反馈「dsh 无法看到工作区文件」。 修复:把 presets.mount 抽成 setupPreset 函数,create 与 resume 共用。 resume 恢复的是会话历史,不是工具注册 —— 两者必须都挂。 实测:旧会话 318f0703 resume 续谈后 setup 回调触发、mount 成功, 模型用 glob 列出工作区文件、read 读取、write/edit 可写,完整回复。
1786 lines
82 KiB
TypeScript
1786 lines
82 KiB
TypeScript
/**
|
||
* dsh-mail-bridge — DeepSeek Harness ↔ AgentMail 桥接插件
|
||
*
|
||
* 与 opencode-mail-bridge 共享同一套 Gateway API。
|
||
* DSH 用 Cordis 插件框架(@deepseek-ai/cordis),不是 opencode 的 @opencode-ai/plugin。
|
||
*
|
||
* 关键差异:
|
||
* - opencode: client.session.create() + client.session.promptAsync()
|
||
* - DSH: ctx.agents.create() + agent.followup()
|
||
* - opencode: tool 用 zod schema
|
||
* - DSH: tool 用 defineTool() + 参数 spec 格式
|
||
*/
|
||
|
||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
||
import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
|
||
import { randomBytes } from 'node:crypto';
|
||
import { homedir } from 'node:os';
|
||
import { join, dirname } from 'node:path';
|
||
import {
|
||
explicitSends,
|
||
noteExplicitSend,
|
||
shouldSkipAutoRelay,
|
||
} from '../lib/relay-dedup.js';
|
||
import { adoptedSessionID, adoptMissingMessage } from '../lib/adopt.js';
|
||
import {
|
||
autoRelayDecision,
|
||
replyInstruction,
|
||
inboundHeadline,
|
||
} from '../lib/relay-policy.js';
|
||
import { clampRelayKey, isPermanentFailure } from '../lib/relay-key.js';
|
||
import { BoundedMap, BoundedSet, MAX_TRACKED_MAILS, MAX_TRACKED_SESSIONS } from '../lib/bounded.js';
|
||
import {
|
||
userMessage,
|
||
replySubject,
|
||
lastAssistantText,
|
||
modelTitle,
|
||
} from '../lib/message.js';
|
||
import { snapshotDshSessions, slugFromTitle } from '../lib/session-snapshot.js';
|
||
import {
|
||
snapshotDshModels,
|
||
modelAttemptOrder,
|
||
renderFailureReport,
|
||
} from '../lib/model-scope.js';
|
||
import { resolveWorkspaceCwd, ensureCwd, mailSessionFallback } from '../lib/workspace.js';
|
||
import { selectCatchup } from '../lib/catchup.js';
|
||
import {
|
||
renderInbox,
|
||
idsToMarkRead,
|
||
formatSize,
|
||
DEFAULT_INBOX_STATUS,
|
||
DEFAULT_INBOX_LIMIT,
|
||
} from '../lib/inbox-format.js';
|
||
import {
|
||
renderNameSuggestions,
|
||
renderPathSuggestions,
|
||
renderSessionSuggestions,
|
||
renderParticipants,
|
||
renderContacts,
|
||
renderThread,
|
||
} from '../lib/discovery.js';
|
||
import { appendRenameProposal, renameProposalNote } from '../lib/rename-proposal.js';
|
||
// 只用 isApproval:DSH 没有 always 语义,免批授权表在这里用不上(见决策处的注释)。
|
||
import { isApproval } from '../lib/permission-grants.js';
|
||
|
||
// ─── 凭证管理 ───
|
||
|
||
const CONFIG_DIR = process.env.AGENTMAIL_CONFIG_DIR || join(homedir(), '.agentmail');
|
||
const KEY_FILE = join(CONFIG_DIR, 'agent.key');
|
||
|
||
function readLocalKey(): string | null {
|
||
try {
|
||
if (!existsSync(KEY_FILE)) return null;
|
||
const raw = JSON.parse(readFileSync(KEY_FILE, 'utf8'));
|
||
return typeof raw.key === 'string' ? raw.key : null;
|
||
} catch { return null; }
|
||
}
|
||
|
||
function saveLocalKey(key: string) {
|
||
try {
|
||
mkdirSync(dirname(KEY_FILE), { recursive: true });
|
||
writeFileSync(KEY_FILE, JSON.stringify({ key }, null, 2), 'utf8');
|
||
} catch { /* 忽略 */ }
|
||
}
|
||
|
||
function generateLocalKey(): string {
|
||
const key = 'ak_' + randomBytes(24).toString('hex');
|
||
saveLocalKey(key);
|
||
return key;
|
||
}
|
||
|
||
// ─── Gateway HTTP 客户端 ───
|
||
|
||
class GatewayClient {
|
||
baseURL: string;
|
||
agentName: string;
|
||
agentKey: string;
|
||
agentSecret: string;
|
||
|
||
constructor(gatewayURL: string, agentName: string, agentKey: string, agentSecret: string) {
|
||
this.baseURL = gatewayURL.replace(/\/+$/, '');
|
||
this.agentName = agentName;
|
||
this.agentKey = agentKey;
|
||
this.agentSecret = agentSecret;
|
||
}
|
||
|
||
/** 与 opencode-mail-bridge 的 authHeaders() 相同逻辑 */
|
||
authHeaders(): Record<string, string> {
|
||
if (this.agentKey) {
|
||
return { Authorization: `Bearer ${this.agentKey}`, 'X-Agent-Name': this.agentName };
|
||
}
|
||
return { 'X-Agent-Name': this.agentName, 'X-Agent-Secret': this.agentSecret };
|
||
}
|
||
|
||
async post(path: string, body: Record<string, unknown>): Promise<any> {
|
||
const res = await fetch(`${this.baseURL}/api/v1${path}`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', ...this.authHeaders() },
|
||
body: JSON.stringify(body),
|
||
});
|
||
const data = await res.json() as any;
|
||
if (!res.ok) {
|
||
// 状态码与响应体挂在 error 上:调用方要区分「暂时失败」与「永远不会成功」。
|
||
// 权限询问碰到 409(任务链上没有人类)必须当场拒绝,
|
||
// 而 502 应该重试 —— 只看 message 字符串分不出这两种。
|
||
const err: any = new Error(data?.error || `POST ${path} failed: ${res.status}`);
|
||
err.status = res.status;
|
||
err.body = data;
|
||
throw err;
|
||
}
|
||
return data;
|
||
}
|
||
|
||
async get(path: string): Promise<any> {
|
||
const res = await fetch(`${this.baseURL}/api/v1${path}`, { headers: this.authHeaders() });
|
||
const data = await res.json() as any;
|
||
if (!res.ok) throw new Error(data?.error || `GET ${path} failed: ${res.status}`);
|
||
return data;
|
||
}
|
||
|
||
async register(): Promise<void> {
|
||
await this.post('/agent/register', {
|
||
name: this.agentName,
|
||
secret: this.agentSecret || '',
|
||
workspaces: [],
|
||
platform: 'dsh',
|
||
});
|
||
}
|
||
}
|
||
|
||
// ─── 会话映射(与 opencode-mail-bridge 相同结构)───
|
||
//
|
||
// 这几张表都是**常驻进程里只增不减**的形态:键来自邮件事件流,会话数随时间
|
||
// 单调增长。两条出口 —— `forgetSession()`(会话归档,确定性)与 Bounded* 的
|
||
// 上限淘汰(兜底)。没有它们,插件跟着 dsh 跑几周之后表里会躺着几十万条再也
|
||
// 不会被查到的条目,而 GC 收不掉(还被强引用着)。
|
||
|
||
const sessionMap = new BoundedMap<string, { dshSessionId: string; directory: string }>(MAX_TRACKED_SESSIONS);
|
||
const reverseMap = new BoundedMap<string, string>(MAX_TRACKED_SESSIONS);
|
||
const mailDrivenSessions = new BoundedSet<string>(MAX_TRACKED_SESSIONS);
|
||
// 回信上下文。fromHuman / inReplyTo 是服务端给的两个信号:
|
||
// 前者决定要不要自动转发(Agent 间不转,见 lib/relay-policy.js),
|
||
// 后者决定提示词说「新任务」还是「你上封信的回复到了」。
|
||
const mailContexts = new BoundedMap<string, {
|
||
replyTo: string; subject: string; mailID: string;
|
||
fromHuman: boolean; inReplyTo: string;
|
||
}>(MAX_TRACKED_SESSIONS);
|
||
const relayedSummaries = new BoundedMap<string, string>(MAX_TRACKED_SESSIONS);
|
||
|
||
// 管理员在配置页划定的可用模型范围(按优先级)。随心跳响应更新。
|
||
// 空数组 = 不限定,回退到环境变量或平台默认。
|
||
let allowedModels: { provider: string; model: string }[] = [];
|
||
const syncedTitles = new BoundedMap<string, string>(MAX_TRACKED_SESSIONS);
|
||
|
||
/**
|
||
* 会话归档 → 忘掉它的全部映射。
|
||
*
|
||
* 归档是个**确定性的终点**:归档后那条会话不可寻址(别名 404),也不会再有新邮件
|
||
* 投进来,`agent/status` 也不该再把总结转回去(会话已经收不了信)。留着这些条目
|
||
* 只是占内存,而上限淘汰是「猜」—— 能确切知道该删的时候就不该靠猜。
|
||
*
|
||
* DSH 侧那个 agent **不 dispose**:人可能还在界面上看它,而且它正在跑的那一轮
|
||
* 不该被归档打断。这里只解除邮件绑定。
|
||
*/
|
||
function forgetSession(mailSessionID: string): void {
|
||
if (!mailSessionID) return;
|
||
// peek 而不是 get:这是清理路径,不该把即将删掉的条目刷成「最近活跃」。
|
||
const bound = sessionMap.peek(mailSessionID);
|
||
mailContexts.delete(mailSessionID);
|
||
sessionMap.delete(mailSessionID);
|
||
const dshSessionId = bound?.dshSessionId;
|
||
if (!dshSessionId) return;
|
||
reverseMap.delete(dshSessionId);
|
||
mailDrivenSessions.delete(dshSessionId);
|
||
relayedSummaries.delete(dshSessionId);
|
||
syncedTitles.delete(dshSessionId);
|
||
}
|
||
|
||
// 权限询问:DSH 的 approval/request 是 waterfall 钩子,插件把它转成邮件问人,
|
||
// 人类决策通过 SSE 回来后再 resolve 这个 promise,让 DSH 自己恢复执行。
|
||
// relay_key 用 `${sessionId}:${toolName}:${callId}` —— DSH 不给询问发 id,
|
||
// 而同一个 callId 的同一个工具只会问一次。
|
||
//
|
||
// **这张表不设上界**(与上面几张不同):它装的是「还在等结果的东西」。静默淘汰
|
||
// 一条会让 DSH 侧那个 `await` 永远不返回 —— 那次工具调用直接挂死。它有确定的
|
||
// 清理路径(决策到达 / 询问被 abort / 拆插件时 fail closed),不需要靠猜。
|
||
interface PendingApproval {
|
||
resolve: (outcome: string) => void;
|
||
sessionId: string;
|
||
}
|
||
const pendingApprovals = new Map<string, PendingApproval>();
|
||
|
||
// 权限被插件主动拒绝时的真正原因 —— 键是 `${agentId}:${callId}`。
|
||
//
|
||
// 为什么需要这张表:DSH 把 `approval/request` 的返回值翻译成模型可见文本时
|
||
// 用的是 **dsh-tools 里写死的句子**(`node_modules/@deepseek-ai/dsh-tools/lib/index.js`):
|
||
//
|
||
// case "rejected": reason = `the user rejected tool "${exec.name}"`
|
||
// case "unavailable": reason = `... no approval channel is available`
|
||
//
|
||
// 于是插件回 'rejected' 时模型看到的是「the user rejected tool bash」——
|
||
// 而实际上**没有任何用户拒绝它**,是「这条链上没有人类可问」或「转发遇到
|
||
// 4xx 永久失败」。服务端给的 suggestion(换不需要权限的方式 / 在回信里请上游
|
||
// 转达)根本没有出口,只进了 journalctl。模型得到的信息既是错的,
|
||
// 也不含任何可行动的提示 —— 它只会以为人在拒绝它,而不会改道。
|
||
//
|
||
// pi(`{block:true, reason}`)与 opencode(`output.reason`)的 reason 直达模型,
|
||
// 只有 DSH 把它吞了。出路是 `tools/post-execute`:门禁拒绝的调用**也会**走
|
||
// post-execute(源码里 `{kind:"post-result"}` → finalizeScheduledExecution
|
||
// → postExecute),而 `{kind:'block', feedback}` 能换掉模型看到的内容。
|
||
interface DeniedReason {
|
||
text: string;
|
||
at: number;
|
||
}
|
||
const deniedReasons = new Map<string, DeniedReason>();
|
||
|
||
/** 10 分钟前的条目不可能还有对应的 post-execute,清掉以免无限增长。 */
|
||
const DENIED_REASON_TTL_MS = 10 * 60 * 1000;
|
||
|
||
function denialKey(agentId: string, callId: unknown): string {
|
||
return `${agentId}:${String(callId ?? 'nocall')}`;
|
||
}
|
||
|
||
function noteDenial(agentId: string, callId: unknown, text: string): void {
|
||
const now = Date.now();
|
||
for (const [k, v] of deniedReasons) {
|
||
if (now - v.at > DENIED_REASON_TTL_MS) deniedReasons.delete(k);
|
||
}
|
||
deniedReasons.set(denialKey(agentId, callId), { text, at: now });
|
||
}
|
||
|
||
function takeDenial(agentId: string, callId: unknown): string | undefined {
|
||
const key = denialKey(agentId, callId);
|
||
const hit = deniedReasons.get(key);
|
||
if (!hit) return undefined;
|
||
deniedReasons.delete(key); // 一次性:同一次调用只能被换一次
|
||
if (Date.now() - hit.at > DENIED_REASON_TTL_MS) return undefined;
|
||
return hit.text;
|
||
}
|
||
|
||
// ─── 运行时导入 DSH 内部函数 ───
|
||
|
||
let _defineTool: any;
|
||
function defineTool(opts: any): any {
|
||
if (!_defineTool) {
|
||
try {
|
||
const dshToolsPath = require.resolve('@deepseek-ai/dsh-tools');
|
||
_defineTool = require(dshToolsPath).defineTool;
|
||
} catch { return opts; }
|
||
}
|
||
return _defineTool(opts);
|
||
}
|
||
|
||
// ─── Cordis 插件入口 ───
|
||
|
||
// Cordis 要求插件声明依赖的服务:没有 inject,ctx.tools / ctx.agents 根本不存在
|
||
// (报 `cannot get property "tools" without inject`)。
|
||
//
|
||
// sessionQuery 不列在这里而用 ctx.get('sessionQuery') 取:inject 是硬依赖,
|
||
// 列进去的服务没挂载时整个插件不会启动 —— 而会话上报只是补全体验,
|
||
// 不应该能把邮件投递整体拘死。
|
||
export const inject = ['agents', 'tools'];
|
||
export const name = 'dsh-mail-bridge';
|
||
|
||
interface PluginConfig {
|
||
gateway: { url: string; agentName: string; agentKey: string; agentSecret: string };
|
||
reply: { provider?: string; model?: string };
|
||
}
|
||
|
||
export function apply(ctx: any, config: PluginConfig): void {
|
||
const GW = config.gateway.url || 'http://127.0.0.1:8180';
|
||
const AGENT_NAME = config.gateway.agentName || 'dsh';
|
||
let AGENT_KEY = config.gateway.agentKey || '';
|
||
const AGENT_SECRET = config.gateway.agentSecret || '';
|
||
const REPLY_PROVIDER = config.reply.provider || '';
|
||
const REPLY_MODEL = config.reply.model || 'AUTO';
|
||
|
||
if (!AGENT_KEY && !AGENT_SECRET) {
|
||
AGENT_KEY = readLocalKey() || generateLocalKey();
|
||
}
|
||
|
||
const client = new GatewayClient(GW, AGENT_NAME, AGENT_KEY, AGENT_SECRET);
|
||
|
||
// 注册 Agent
|
||
(async () => {
|
||
try {
|
||
await client.register();
|
||
ctx.logger.info(`[dsh-mail-bridge] 已接入 ${GW},身份 ${AGENT_NAME}`);
|
||
} catch (e: any) {
|
||
ctx.logger.error(`[dsh-mail-bridge] 注册失败: ${e?.message || e}`);
|
||
}
|
||
})();
|
||
|
||
// ─── 心跳 + 平台会话上报 ───
|
||
//
|
||
// 心跳两个职责:
|
||
// 1. 保活 —— Gateway 靠 last_seen 判在线,不发心跳就会被当成离线(之前真的没发)
|
||
// 2. 上报平台侧会话快照 —— 写信时想续谈某条会话,得先知道那个工作区下
|
||
// 有哪些会话可续;Gateway 只看得见邮件驱动的那部分。
|
||
//
|
||
// 上报而不是让 Gateway 反向拉取:当前架构是单向的(Agent 持密钥主动连
|
||
// Gateway,Gateway 从不外呼),反向拉取需要它保存各平台的地址与凭证。
|
||
|
||
/**
|
||
* 收集本机 DSH 的会话快照。
|
||
*
|
||
* 优先用 ctx.sessionQuery(包含已落盘的历史会话),它不可用时退到
|
||
* ctx.agents.list()(只有当前活着的)。base bundle 里 session-query-sqlite 的
|
||
* openAt 是 'never',但 listSessions/readTitle 这些精确读不依赖 SQLite
|
||
* —— 只有全文搜索会报 SESSION_QUERY_SEARCH_DISABLED。
|
||
*
|
||
* 返回 undefined 表示「本次拿不到列表」,调用方应当省略字段而不是传空数组:
|
||
* 空数组的语义是「平台侧确实一条会话都没有」,会把服务端的镜像抹掉。
|
||
*/
|
||
async function collectSessions(): Promise<any[] | undefined> {
|
||
const q = ctx.get('sessionQuery');
|
||
if (q?.listSessions) {
|
||
try {
|
||
const records = await q.listSessions();
|
||
const ids = records
|
||
.map((r: any) => r?.header?.id)
|
||
.filter((id: any): id is string => typeof id === 'string');
|
||
// 标题在日志里,需要单独 fold。批量读而不是逐个读:
|
||
// readTitleSnapshots 共用一次 corpus 观测,而逐个 readTitle 会重复加载日志。
|
||
const titles = new Map<string, string>();
|
||
if (q.readTitleSnapshots && ids.length > 0) {
|
||
const results = await q.readTitleSnapshots(ids);
|
||
results.forEach((res: any, i: number) => {
|
||
// 单个会话读失败不该拘到其他会话(接口本身就是逐条隔离的)
|
||
if (res?.status === 'rejected') return;
|
||
const title = res?.value?.title?.title ?? res?.title?.title;
|
||
if (typeof title === 'string' && title) titles.set(ids[i], title);
|
||
});
|
||
}
|
||
return records.map((r: any) => ({
|
||
id: r?.header?.id,
|
||
cwd: r?.header?.cwd ?? '',
|
||
title: titles.get(r?.header?.id) ?? '',
|
||
updatedAt: r?.header?.createdAt,
|
||
// subagent 子会话要被过滤掉,判据在 header 上
|
||
origin: r?.header?.origin,
|
||
delegationDepth: r?.header?.delegationDepth,
|
||
}));
|
||
} catch (e: any) {
|
||
ctx.logger.warn(`[dsh-mail-bridge] sessionQuery 不可用,退到活会话列表: ${e?.message || e}`);
|
||
}
|
||
}
|
||
|
||
// 退路:只报当前活着的 agent。比什么都不报强 ——
|
||
// 它们恰好是正在进行的任务,也是最可能被续谈的那批。
|
||
try {
|
||
const live = ctx.agents?.list?.() ?? [];
|
||
return live.map((a: any) => ({
|
||
id: String(a?.id ?? ''),
|
||
cwd: a?.session?.header?.cwd ?? '',
|
||
title: modelTitle(a?.session?.events ?? []),
|
||
updatedAt: a?.session?.header?.createdAt,
|
||
origin: a?.session?.header?.origin,
|
||
delegationDepth: a?.session?.header?.delegationDepth,
|
||
}));
|
||
} catch {
|
||
return undefined;
|
||
}
|
||
}
|
||
|
||
// 已经投过的 mail_id。心跳与 SSE 建连之间有个窗口:那期间到的邮件
|
||
// 既在 pending_mails 里、也会被 SSE 推一次 —— 不去重就会投两遍。
|
||
//
|
||
// 有界:插件跟着 dsh 长期活着,这里会攒下每一封处理过的邮件 id 而永远没有
|
||
// 出口。淘汰是安全的 —— 它防的两种重复都发生在秒到分钟级。
|
||
const deliveredMails = new BoundedSet<string>(MAX_TRACKED_MAILS);
|
||
let caughtUp = false;
|
||
|
||
/**
|
||
* 补投离线期间积压的未读邮件。
|
||
*
|
||
* SSE 只推连上之后的事件,插件重启前发来的邮件不会再推一次。
|
||
* 不补的话那封邮件永远躺在收件箱里,而发件人以为 Agent 收到了。
|
||
*/
|
||
async function catchUp(pending: unknown): Promise<void> {
|
||
if (!pending) return;
|
||
try {
|
||
const box = await client.get('/mail/inbox?status=unread&limit=20');
|
||
const tasks = selectCatchup(box?.mails ?? box, deliveredMails);
|
||
if (tasks.length === 0) return;
|
||
console.error(`[dsh-mail-bridge] 补投 ${tasks.length} 封离线期间的邮件(共 ${pending} 封未读)`);
|
||
// 串行:每封都要起一轮模型,并发放出去等于对上游打 N 个并发请求
|
||
for (const ev of tasks) {
|
||
// 逐封再查一次:拉收件箱和逐封投递之间 SSE 可能已经投过其中某封
|
||
if (deliveredMails.has(ev.mail_id)) continue;
|
||
deliveredMails.add(ev.mail_id);
|
||
try {
|
||
await deliverMail(ev, 'mail');
|
||
} catch (e: any) {
|
||
console.error(`[dsh-mail-bridge] 补投 ${ev.mail_id} 失败: ${e?.message || e}`);
|
||
}
|
||
}
|
||
} catch (e: any) {
|
||
console.error(`[dsh-mail-bridge] 补投失败: ${e?.message || e}`);
|
||
}
|
||
}
|
||
|
||
async function beat(): Promise<void> {
|
||
const body: Record<string, unknown> = {};
|
||
// DSH 有真沙箱(read-only / workspace-write / danger-full-access),
|
||
// 档位在这里是被强制执行的,不是 advisory。
|
||
body.mode_enforcement = 'native';
|
||
const [entries, models] = await Promise.all([collectSessions(), collectModels()]);
|
||
if (entries) {
|
||
body.platform_sessions = snapshotDshSessions(entries, (id) => mailDrivenSessions.has(id));
|
||
}
|
||
if (models) body.models = models;
|
||
try {
|
||
const res = await client.post('/agent/heartbeat', body);
|
||
// 生效的模型范围随心跳响应回传:管理员在配置页改了范围后,
|
||
// 插件最多一个周期(30 秒)就能看到新值,不需要重启。
|
||
if (Array.isArray(res?.allowed_models)) allowedModels = res.allowed_models;
|
||
// 只在首个成功的心跳后补投一次:之后的积压都由 SSE 覆盖,
|
||
// 每轮心跳都补的话会把「模型正在处理中、尚未标已读」的邮件重复投递。
|
||
if (!caughtUp) {
|
||
caughtUp = true;
|
||
await catchUp(res?.pending_mails);
|
||
}
|
||
} catch {
|
||
// 心跳失败不报错:网络抖动很常见,下一轮会补上。
|
||
// 真的持续连不上时 Gateway 会把它判成离线,那才是可见的信号。
|
||
}
|
||
}
|
||
|
||
ctx.effect(() => {
|
||
void beat();
|
||
const timer = setInterval(() => { void beat(); }, 30_000);
|
||
return () => clearInterval(timer);
|
||
}, 'dsh-mail-bridge.heartbeat');
|
||
|
||
// ─── 获取默认模型 ───
|
||
|
||
/**
|
||
* 这一轮按什么顺序尝试模型。
|
||
*
|
||
* 优先级:管理员划定的范围 > 环境变量指定 > 平台自己的默认选择。
|
||
* 范围是运行时可改的策略,环境变量是部署时的兜底,因此前者优先。
|
||
*/
|
||
function attemptOrder(): ({ provider: string; model: string } | undefined)[] {
|
||
const envDefault = REPLY_PROVIDER && REPLY_MODEL
|
||
? { provider: REPLY_PROVIDER, model: REPLY_MODEL }
|
||
: ctx.get('agentDefaultModel')?.currentSelection?.();
|
||
return modelAttemptOrder(allowedModels, envDefault);
|
||
}
|
||
|
||
/** 收集本机 DSH 看得见的模型目录,供配置页勾选。 */
|
||
async function collectModels(): Promise<any[] | undefined> {
|
||
const llm = ctx.get('llm');
|
||
if (!llm?.listProviders || !llm?.listModels) return undefined;
|
||
try {
|
||
const flat: any[] = [];
|
||
for (const p of llm.listProviders()) {
|
||
const id = p?.provider ?? p?.id;
|
||
if (typeof id !== 'string' || !id) continue;
|
||
try {
|
||
const models = await llm.listModels(id);
|
||
for (const m of models) flat.push(m);
|
||
} catch {
|
||
// 单个 provider 拉不到不该拖掉其他 provider ——
|
||
// 上游故障通常只影响一家
|
||
}
|
||
}
|
||
return snapshotDshModels(flat);
|
||
} catch {
|
||
// 拉不到就省略该字段,而不是上报空数组把配置页清成空白
|
||
return undefined;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 等这个会话的首轮真正跑起来,或者失败。
|
||
*
|
||
* **`ctx.agents.create()` 不会因为模型无效而失败** —— 它只是记下 agentOptions。
|
||
* 真正的失败发生在之后的 turn 里,异步抛出:
|
||
*
|
||
* request/context {provider: "nonexistent", model: "x"}
|
||
* turn/end {reason: {kind: "error", error: {code: "NO_ADAPTER", ...}}}
|
||
*
|
||
* 因此降级尝试不能只包一个 try/catch —— 那样第二个模型永远不会被试到。
|
||
* 这里用 `session/event` 观察 turn 的走向。
|
||
*
|
||
* **`assistant/chunk` 本身不是成功信号**:它的 `finish` 子类型也带错误 ——
|
||
*
|
||
* {chunk: {type: 'finish', reason: {kind: 'error', failure: {code: 'NO_ADAPTER'}}}}
|
||
*
|
||
* 见过一次「无效 provider 却判成功」正是因为把任意 chunk 当成了走通。
|
||
* 判据要落在 chunk 的类型上:`finish` 看 reason,其余(`block-start`、
|
||
* `text-delta`、`tool-call-delta`…)才意味着模型真的在产出。
|
||
*
|
||
* 超时按「成功」处理:模型可能只是很慢(首 token 前要装载上下文),
|
||
* 把慢当成失败会在换模型的同时把已经在跑的那一轮丢掉。
|
||
*
|
||
* @param agent 刚建好的 agent
|
||
* @param timeoutMs 判定窗口
|
||
*/
|
||
function awaitFirstTurn(agent: any, timeoutMs = 60_000): Promise<{ ok: true } | { ok: false; error: string }> {
|
||
return new Promise((resolve) => {
|
||
let done = false;
|
||
const finish = (r: { ok: true } | { ok: false; error: string }) => {
|
||
if (done) return;
|
||
done = true;
|
||
clearTimeout(timer);
|
||
dispose?.();
|
||
resolve(r);
|
||
};
|
||
const timer = setTimeout(() => finish({ ok: true }), timeoutMs);
|
||
/** 把 DSH 的错误对象拼成一行可读文本。 */
|
||
const describe = (err: any): string =>
|
||
[err?.code, err?.message].filter(Boolean).join(': ') || '未知错误';
|
||
|
||
const dispose = ctx.on('session/event', (session: any, event: any) => {
|
||
if (session !== agent.session) return;
|
||
|
||
if (event?.type === 'assistant/chunk') {
|
||
const chunk = event.data?.chunk;
|
||
if (chunk?.type === 'finish') {
|
||
if (chunk.reason?.kind === 'error') {
|
||
return finish({ ok: false, error: describe(chunk.reason.failure) });
|
||
}
|
||
return; // finish 收尾,等 turn/end
|
||
}
|
||
// assistant/chunk 模型在产出内容 → 模型确实活着,算 OK
|
||
return finish({ ok: true });
|
||
}
|
||
|
||
if (event?.type === 'turn/end') {
|
||
const reason = event.data?.reason;
|
||
if (reason?.kind === 'error') {
|
||
return finish({ ok: false, error: describe(reason.error) });
|
||
}
|
||
return finish({ ok: true });
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 等待当前轮次结束后再投递下一封。
|
||
*
|
||
* 原来 catchUp 用 awaitFirstTurn(首 token 即放行),同一会话的多封邮件在
|
||
* 补投时全部撞进同一个 turn → 「message undefined is already pending」。
|
||
* 这个函数等 turn/end,且对同一会话串行化,杜绝并发 followup。
|
||
*/
|
||
async function waitForTurnEnd(agent: any, timeoutMs = 120_000): Promise<void> {
|
||
await new Promise<void>((resolve) => {
|
||
let done = false;
|
||
const finish = () => { if (!done) { done = true; clearTimeout(timer); dispose?.(); resolve(); } };
|
||
const timer = setTimeout(finish, timeoutMs);
|
||
const dispose = ctx.on('session/event', (session: any, event: any) => {
|
||
if (session !== agent.session) return;
|
||
if (event?.type === 'turn/end') finish();
|
||
});
|
||
});
|
||
}
|
||
|
||
/** 按会话锁串行化 followup,同一会话同一时刻只跑一轮。 */
|
||
const sessionLocks = new Map<string, Promise<void>>();
|
||
async function locked<T>(dshSessionId: string, fn: () => Promise<T>): Promise<T> {
|
||
const prev = sessionLocks.get(dshSessionId) ?? Promise.resolve();
|
||
let release!: () => void;
|
||
const next = new Promise<void>((r) => { release = r; });
|
||
sessionLocks.set(dshSessionId, next);
|
||
try {
|
||
await prev;
|
||
return await fn();
|
||
} finally {
|
||
release();
|
||
if (sessionLocks.get(dshSessionId) === next) sessionLocks.delete(dshSessionId);
|
||
}
|
||
}
|
||
|
||
// ─── 建会话(磁盘上已有则 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 } : {};
|
||
|
||
// 注册 standard preset 的工具(bash、fs、fs-search 等)。
|
||
// dsh-a2a 用同一套 API(ctx.get('agentPresets') + presets.mount)并稳定运行。
|
||
//
|
||
// create 与 resume 都必须挂:resume 从磁盘恢复的是**会话历史**,
|
||
// 不是工具注册 —— 工具是 setup 回调里 mount 的,resume 不传 setup
|
||
// 就等于回到 setup:undefined 时代(邮件会话没有任何文件工具)。
|
||
const setupPreset = async (agentCtx: any) => {
|
||
console.error(`[dsh-mail-bridge] setup 回调触发,agentCtx keys=${JSON.stringify(Object.keys(agentCtx || {}))}`);
|
||
try {
|
||
const presets = ctx.get('agentPresets');
|
||
console.error(`[dsh-mail-bridge] agentPresets: ${presets ? '存在' : '不存在'}`);
|
||
if (presets) {
|
||
const preset = await presets.resolve('standard');
|
||
console.error(`[dsh-mail-bridge] standard preset: id=${preset?.id} keys=${JSON.stringify(Object.keys(preset || {}))}`);
|
||
if (preset?.id) {
|
||
await presets.mount(agentCtx, preset.id);
|
||
console.error(`[dsh-mail-bridge] presets.mount 成功`);
|
||
} else {
|
||
console.error(`[dsh-mail-bridge] standard preset 无 id,跳过 mount`);
|
||
}
|
||
}
|
||
} catch (e: any) {
|
||
console.error(`[dsh-mail-bridge] presets.mount 失败(工具注册降级): ${e?.message || e}\n${e?.stack?.slice(0,300)}`);
|
||
}
|
||
};
|
||
|
||
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: setupPreset,
|
||
});
|
||
return { handle, resumed: true };
|
||
}
|
||
|
||
const handle = await ctx.agents.create({
|
||
sessionId,
|
||
meta: { cwd },
|
||
agentOptions,
|
||
setup: setupPreset,
|
||
});
|
||
return { handle, resumed: false };
|
||
}
|
||
|
||
// ─── 权限档位 ───
|
||
//
|
||
// DSH 的权限由两个独立旋钮控制:
|
||
// sandbox/mode → 控制文件系统和命令执行的边界
|
||
// approval/policy → 控制是否需要人类审批
|
||
//
|
||
// 三档映射(见 lib/permission-mode.js dshSandboxMode / dshApprovalPolicy):
|
||
// plan → read-only + ask(只读,遇到权限询问转邮件)
|
||
// workspace → workspace-write + ask(目录内可写,越界转邮件)
|
||
// full → danger-full-access + never(完全放开,不问人)
|
||
//
|
||
// 直接往 session 上 append 事件(与 permissionPresets.set 同一底层)。
|
||
// 不走 permissionPresets 服务:它要求 preset 名在配置表里,
|
||
// 而我们的三档映射需要完全自主控制。
|
||
function applyPermissionMode(session: any, mode: string): void {
|
||
if (!session?.append) return;
|
||
const m = (mode || 'workspace').trim();
|
||
// sandbox mode
|
||
const sandboxMap: Record<string, string> = {
|
||
plan: 'read-only',
|
||
workspace: 'workspace-write',
|
||
full: 'danger-full-access',
|
||
};
|
||
const sandbox = sandboxMap[m] ?? 'workspace-write';
|
||
session.append('sandbox/mode', { mode: sandbox });
|
||
// approval policy
|
||
const approvalMap: Record<string, string> = {
|
||
plan: 'ask',
|
||
workspace: 'ask',
|
||
full: 'never',
|
||
};
|
||
const approval = approvalMap[m] ?? 'ask';
|
||
session.append('approval/policy', { policy: approval });
|
||
}
|
||
|
||
// ─── 投递邮件到 DSH 会话 ───
|
||
|
||
/**
|
||
* 投进「被接管的平台会话」时给模型的提示词。
|
||
*
|
||
* 与新建会话那份的差别:不自我介绍身份、不解释邮件系统 —— 这条会话里人已经
|
||
* 在谈别的事了,一段「你是 dsh,你收到一封邮件」的开场白会让模型以为上下文
|
||
* 被重置。只说「有封邮件进来了」。
|
||
*/
|
||
function adoptPrompt(data: any, kind: string): string {
|
||
if (kind === 'permission') {
|
||
return `你之前发起的权限请求已有结论:${data.decision}(决策人:${data.decided_by || '用户'})。请据此继续后续工作。`;
|
||
}
|
||
const fromHuman = data.from_human === true;
|
||
return [
|
||
inboundHeadline({ inReplyTo: data.in_reply_to, fromHuman, reused: true }),
|
||
``,
|
||
`发件人:${data.from_name || 'unknown'}`,
|
||
`主题:${data.subject || '(无主题)'}`,
|
||
`邮件 ID:${data.mail_id || 'unknown'}`,
|
||
...(data.in_reply_to ? [`回的是你那封:${data.in_reply_to}`] : []),
|
||
...(data.reply_address ? [`回信地址:${data.reply_address}`] : []),
|
||
``,
|
||
`请先调用 read_inbox 读取完整正文,然后处理其中的请求。`,
|
||
...replyInstruction({ fromHuman, replyAddress: data.reply_address }),
|
||
].join('\n');
|
||
}
|
||
|
||
/**
|
||
* 记下「这条邮件会话 ↔ 这条平台会话」的绑定与回信上下文。
|
||
*
|
||
* mailDrivenSessions 必须加:接管之后这条会话**开始**参与邮件往来,轮次结束
|
||
* 要把总结转回发件人。不加的话邮件投进去了却永远没有回音。
|
||
*/
|
||
function bindAdopted(mailSessionID: string, dshSessionId: string, cwd: string, data: any): void {
|
||
if (!mailSessionID) return;
|
||
sessionMap.set(mailSessionID, { dshSessionId, directory: cwd });
|
||
reverseMap.set(dshSessionId, mailSessionID);
|
||
mailDrivenSessions.add(dshSessionId);
|
||
mailContexts.set(mailSessionID, {
|
||
replyTo: data.from_name || '',
|
||
subject: data.subject || '',
|
||
mailID: data.mail_id || '',
|
||
fromHuman: data.from_human === true,
|
||
inReplyTo: data.in_reply_to || '',
|
||
});
|
||
}
|
||
|
||
async function deliverMail(data: any, kind: string): Promise<{ sessionID: string; reused: boolean }> {
|
||
const mailSessionID = data.session_id;
|
||
const existing = mailSessionID ? sessionMap.get(mailSessionID) : undefined;
|
||
|
||
// 服务端说这条邮件会话**接管了平台上已经存在的那条会话**(人在 DSH 界面上
|
||
// 开的那种)—— 投进它而不是新建。
|
||
//
|
||
// TUI 与邮箱是同一个 Agent 的两个入口,不是两套隔离的世界。补全早就把平台
|
||
// 会话列为候选(session-snapshot 上报的那批),这一跳补上投递侧。
|
||
//
|
||
// DSH 上不需要新代码路径:`startAgent` 本来就「磁盘上有就 resume」,
|
||
// 接管只是把会话 id 从 `mail-<uuid>` 换成平台自己那个。第一次投递走
|
||
// resume 分支装回上下文,之后与普通续谈完全一样(sessionMap 命中 → followup)。
|
||
const adoptedID = adoptedSessionID(data);
|
||
if (!existing && adoptedID) {
|
||
const onDisk = await persistedCwd(adoptedID);
|
||
if (onDisk === undefined) {
|
||
// 平台侧那条会话已不在磁盘上。
|
||
//
|
||
// 不能落到「新开会话」那条路 —— 那会用 `mail-<uuid>` 另开一条,
|
||
// 人在 DSH 界面上看不到这封邮件带来的对话,而那正是接管的目的(N-8)。
|
||
//
|
||
// 用 console.error 而不是仅靠抛异常:调用方那层的 catch 走
|
||
// `ctx.logger.error`,而 DSH 的 logger **不进 journalctl**。邮件因此会
|
||
// 静默消失:发件人以为送到了,而日志里一个字都没有(实测过)。
|
||
console.error(`[dsh-mail-bridge] 接管失败:会话 ${adoptedID} 不在本机磁盘上`
|
||
+ `(mail ${data.mail_id},发件人 ${data.from_name || '?'})。`
|
||
+ `若这个 id 属于另一个平台,说明 Gateway 把别人的 platform_session_id `
|
||
+ `推给了本插件。`);
|
||
throw new Error(adoptMissingMessage(adoptedID, '磁盘上已无这条会话的日志'));
|
||
}
|
||
return locked(adoptedID, async () => {
|
||
const promptText = adoptPrompt(data, kind);
|
||
const live = ctx.agents.get(adoptedID);
|
||
if (live) {
|
||
// 界面上正开着这条会话 —— 直接 followup,不要再 resume 一次:
|
||
// 同一条会话两个 handle 会各自往日志里写,replay 校验过不去。
|
||
live.followup(userMessage(promptText));
|
||
await waitForTurnEnd(live);
|
||
bindAdopted(mailSessionID, adoptedID, onDisk, data);
|
||
return { sessionID: adoptedID, reused: true };
|
||
}
|
||
const { handle } = await startAgent(adoptedID, onDisk, attemptOrder()[0]);
|
||
applyPermissionMode(handle.agent?.session, data.permission_mode);
|
||
bindAdopted(mailSessionID, adoptedID, onDisk, data);
|
||
handle.agent.followup(userMessage(promptText));
|
||
await waitForTurnEnd(handle.agent);
|
||
return { sessionID: adoptedID, reused: true };
|
||
});
|
||
}
|
||
|
||
if (existing) {
|
||
const live = ctx.agents.get(existing.dshSessionId);
|
||
if (live) {
|
||
return locked(existing.dshSessionId, async () => {
|
||
// 回信上下文必须刷成**这一封**。
|
||
//
|
||
// 续谈分支原来不刷:mailContexts 只在 bindAdopted 与新开会话时写一次,
|
||
// 于是同一条会话的第二封邮件跑完后,自动转发用的还是**第一封**的
|
||
// subject / mailID —— 回信主题与 parent_mail_id 都指向上一封。
|
||
// 实测撞出过:回「全流程回归」那封的信,主题写的是上一封
|
||
// 「platformID 归属验证」,parent 也挂在那封上。
|
||
//
|
||
// 与 pi 桥的语义对齐:那边每封邮件都重建 mailContext(worker 一封一进程),
|
||
// 注释写的就是「一个会话里可能来过多封信,只留最近那封」。
|
||
if (kind === 'mail' && mailSessionID) {
|
||
mailContexts.set(mailSessionID, {
|
||
replyTo: data.from_name || '',
|
||
subject: data.subject || '',
|
||
mailID: data.mail_id || '',
|
||
fromHuman: data.from_human === true,
|
||
inReplyTo: data.in_reply_to || '',
|
||
});
|
||
}
|
||
const promptText = kind === 'permission'
|
||
? `你之前发起的权限请求已有结论:${data.decision}(决策人:${data.decided_by || '用户'})。请据此继续后续工作。`
|
||
: [
|
||
inboundHeadline({
|
||
inReplyTo: data.in_reply_to,
|
||
fromHuman: data.from_human === true,
|
||
reused: true,
|
||
}),
|
||
``,
|
||
`发件人:${data.from_name || 'unknown'}`,
|
||
`主题:${data.subject || '(无主题)'}`,
|
||
`邮件 ID:${data.mail_id || 'unknown'}`,
|
||
...(data.in_reply_to ? [`回的是你那封:${data.in_reply_to}`] : []),
|
||
...(data.reply_address ? [`回信地址:${data.reply_address}`] : []),
|
||
``,
|
||
`请先调用 read_inbox 读取完整正文,然后处理其中的请求。`,
|
||
...replyInstruction({
|
||
fromHuman: data.from_human === true,
|
||
replyAddress: data.reply_address,
|
||
}),
|
||
].join('\n');
|
||
live.followup(userMessage(promptText));
|
||
// 等 turn/end 而不是立即返回:这封邮件的轮次未结束时投递下一封,
|
||
// 会让 DSH 报 "message already pending"。串行化靠 locked() 保证
|
||
// 同一时刻只有一个 followup 在跑,两个锁互斥 —— 即使 turn/end
|
||
// 未出现(比如模型完全没响应),120s 超时兜底不会把后续邮件永久卡住。
|
||
await waitForTurnEnd(live);
|
||
return { sessionID: existing.dshSessionId, reused: true };
|
||
});
|
||
}
|
||
}
|
||
|
||
// 新开会话
|
||
const sessionId = `mail-${mailSessionID || Date.now()}`;
|
||
// cwd 取寻址里的 path 位(to_workspace)。
|
||
//
|
||
// 之前这里硬拼 `~/.dsh/mail-sessions/mail-<uuid>` —— 每封邮件一个全新的空目录。
|
||
// DSH 按 cwd 给会话分组,于是所有邮件会话既不属于任何项目、彼此也不同组,
|
||
// 界面上全落进「未分组」。path 位本来就是「希望它在哪儿干活」。
|
||
const { cwd, grouped } = resolveWorkspaceCwd(
|
||
data.to_workspace, mailSessionFallback(sessionId));
|
||
ensureCwd(cwd, grouped);
|
||
if (!grouped && data.to_workspace) {
|
||
ctx.logger.warn(
|
||
`[dsh-mail-bridge] 工作目录 ${data.to_workspace} 不可用,回退到 ${cwd}`);
|
||
}
|
||
|
||
const promptText = kind === 'permission'
|
||
? `你之前发起的权限请求已有结论:${data.decision}(决策人:${data.decided_by || '用户'})。请据此继续。`
|
||
: [
|
||
inboundHeadline({
|
||
inReplyTo: data.in_reply_to,
|
||
fromHuman: data.from_human === true,
|
||
catchup: data.catchup,
|
||
reused: false,
|
||
}),
|
||
``,
|
||
`发件人:${data.from_name || 'unknown'}`,
|
||
`主题:${data.subject || '(无主题)'}`,
|
||
`邮件 ID:${data.mail_id || 'unknown'}`,
|
||
...(data.in_reply_to ? [`回的是你那封:${data.in_reply_to}`] : []),
|
||
`身份:你是 ${AGENT_NAME}`,
|
||
...(data.reply_address ? [`回信地址:${data.reply_address}`] : []),
|
||
``,
|
||
`请先调用 read_inbox 读取完整正文(附带附件清单,如有附件可用 download_attachment 取回),然后处理其中的请求。`,
|
||
``,
|
||
...replyInstruction({
|
||
fromHuman: data.from_human === true,
|
||
replyAddress: data.reply_address,
|
||
}),
|
||
].join('\n');
|
||
|
||
// 按管理员划定的范围逐个尝试建 agent,全部失败才回一封说明原因的邮件。
|
||
//
|
||
// 必须发那封信:agent 一次都没建起来时会话里没有任何 assistant 消息,
|
||
// 自动转发因此什么也不会发 —— 发件人只会看到邮件发出去后再无音讯。
|
||
//
|
||
// DSH 与 opencode 的差异:这里失败在**建 agent** 时就暴露(模型路由是
|
||
// agentOptions 的一部分),而 opencode 是在 promptAsync 时。
|
||
const attempts = attemptOrder();
|
||
const failures: { provider?: string; model?: string; error: string }[] = [];
|
||
|
||
for (let i = 0; i < attempts.length; i++) {
|
||
const route = attempts[i];
|
||
const label = route ? `${route.provider}/${route.model}` : '(平台默认)';
|
||
// 每次尝试用不同的会话 id:失败那次的日志里已经有 turn/end error,
|
||
// 复用同一个 id 会让重试接在一条已经出错的会话后面。
|
||
const attemptSessionId = i === 0 ? sessionId : `${sessionId}-r${i}`;
|
||
let handle: any;
|
||
|
||
try {
|
||
const started = await startAgent(attemptSessionId, cwd, route);
|
||
handle = started.handle;
|
||
// 设定权限档位:sandbox/mode 决定文件与命令边界,
|
||
// approval/policy 决定越界时是否转邮件问人。
|
||
applyPermissionMode(handle.agent?.session, data.permission_mode);
|
||
} catch (e: any) {
|
||
// create/resume 本身很少失败(create 不校验模型),
|
||
// 但 cwd 不符、日志 replay 不过之类仍会抛
|
||
failures.push({ ...(route ?? {}), error: e?.message || String(e) });
|
||
console.error(`[dsh-mail-bridge] 建会话失败 ${label}: ${e?.message || e}`);
|
||
continue;
|
||
}
|
||
|
||
// 先建映射再 followup:turn 可能在 followup 返回前就产出事件,
|
||
// 而 agent/status 的处理要靠这些映射找到回信地址。
|
||
if (mailSessionID) {
|
||
sessionMap.set(mailSessionID, { dshSessionId: attemptSessionId, directory: cwd });
|
||
reverseMap.set(attemptSessionId, mailSessionID);
|
||
mailDrivenSessions.add(attemptSessionId);
|
||
mailContexts.set(mailSessionID, {
|
||
replyTo: data.from_name || '',
|
||
subject: data.subject || '',
|
||
mailID: data.mail_id || '',
|
||
fromHuman: data.from_human === true,
|
||
inReplyTo: data.in_reply_to || '',
|
||
});
|
||
}
|
||
|
||
// 把会话挂进工作区注册表 —— 这是让 DSH GUI 把它从「未分组」挪进对应项目
|
||
// 的唯一路径。不挂的后果:会话永远落在 Ungrouped,人类在侧栏看不到它
|
||
// 与项目的从属关系,也无法用 DSH 的工作区级操作(批量归档、重命名等)。
|
||
//
|
||
// **cwd 必须取自会话 header,不能用上面那个 `cwd` 变量。**
|
||
//
|
||
// 上一版就错在这里:那个变量是 `resolveWorkspaceCwd` 的结果,可能是**兜底值**
|
||
// (`~/.dsh/mail-sessions/mail-<uuid>`);而 resume 路径下会话的真实 cwd 取自
|
||
// 持久化的 header,两者不一致时 `attachSession` 的校验直接拒绝:
|
||
//
|
||
// cannot attach session 'mail-f3d824ce…' to workspace
|
||
// '/root/.dsh/mail-sessions/mail-f3d824ce…': its cwd resolves to '/home/program/llmsproxy'
|
||
//
|
||
// 更糟的是 `wr.create()` 已经先执行了 —— 于是每封邮件都往注册表里
|
||
// 塞一条永远为空的垃圾 workspace。先读 header 再注册就不会有这个问题。
|
||
//
|
||
// 失败不可怕:会话本身已经能用了,只是 GUI 分组不对。
|
||
if (handle) {
|
||
try {
|
||
// 真实 cwd:create 路径下就是上面传的 meta.cwd,
|
||
// resume 路径下是持久化 header 里那个。两种情形都从 session 读。
|
||
const actualCwd = String(handle.agent?.session?.header?.cwd ?? '');
|
||
const wr: any = (ctx as any).get?.('workspaceRegistry');
|
||
if (actualCwd && wr?.create) {
|
||
const ws = await wr.create(actualCwd, actualCwd.split('/').pop() || actualCwd);
|
||
if (ws?.attachSession) {
|
||
await ws.attachSession(attemptSessionId as any);
|
||
}
|
||
}
|
||
} catch (e: any) {
|
||
// 工作区注册失败不影响会话功能:只有 GUI 分组会受影响。
|
||
// 日志用 console.error 才进 journalctl(ctx.logger 不进)。
|
||
console.error(`[dsh-mail-bridge] 工作区注册失败(不影响功能): ${e?.message || e}`);
|
||
}
|
||
}
|
||
|
||
const watching = awaitFirstTurn(handle.agent);
|
||
handle.agent.followup(userMessage(promptText));
|
||
const outcome = await watching;
|
||
|
||
if (outcome.ok) {
|
||
if (failures.length > 0) {
|
||
console.error(`[dsh-mail-bridge] ${label} 成功(前 ${failures.length} 个失败)`);
|
||
}
|
||
return { sessionID: attemptSessionId, reused: false };
|
||
}
|
||
|
||
failures.push({ ...(route ?? {}), error: outcome.error });
|
||
console.error(`[dsh-mail-bridge] 模型 ${label} 失败: ${outcome.error}`);
|
||
// 拆掉这一路的 agent 与映射,否则它会占着会话 id,
|
||
// 而 agent/status 还会为这个死会话触发一次自动转发
|
||
reverseMap.delete(attemptSessionId);
|
||
mailDrivenSessions.delete(attemptSessionId);
|
||
try {
|
||
await handle.dispose();
|
||
} catch {
|
||
// dispose 失败不影响换下一个模型
|
||
}
|
||
}
|
||
if (mailSessionID) sessionMap.delete(mailSessionID);
|
||
|
||
// 全部失败:把原因作为邮件回给发件人。走免配额通道 ——
|
||
// 这是插件的故障报告,不是模型的自主发信。
|
||
if (kind === 'mail' && data.from_name) {
|
||
try {
|
||
await client.post('/mail/send', {
|
||
to: data.from_name,
|
||
subject: `处理失败: ${data.subject || '(无主题)'}`,
|
||
body: renderFailureReport(failures, data.subject),
|
||
reply_to: data.mail_id || '',
|
||
relay: 'summary',
|
||
relay_key: clampRelayKey(`model-failure:${data.mail_id || sessionId}`),
|
||
});
|
||
ctx.logger.info(`[dsh-mail-bridge] 已回报模型调用失败给 ${data.from_name}`);
|
||
} catch (e: any) {
|
||
ctx.logger.error(`[dsh-mail-bridge] 失败回报也发不出去: ${e?.message || e}`);
|
||
}
|
||
}
|
||
throw new Error(
|
||
`划定范围内的 ${failures.length} 个模型全部失败:` +
|
||
failures.map(f => f.error).join(' | '));
|
||
}
|
||
|
||
// ─── SSE 监听(与 opencode-mail-bridge 相同的 fetch + reader 模式)───
|
||
|
||
let sseAbort: AbortController | null = null;
|
||
|
||
function startSSE(onEvent: (type: string, data: any) => void) {
|
||
sseAbort?.abort();
|
||
sseAbort = new AbortController();
|
||
|
||
const reconnect = () => {
|
||
if (sseAbort?.signal.aborted) return;
|
||
|
||
fetch(`${GW}/api/v1/events/stream`, {
|
||
headers: client.authHeaders(),
|
||
signal: sseAbort?.signal ?? new AbortController().signal,
|
||
}).then((res) => {
|
||
const reader = res.body?.getReader();
|
||
if (!reader) return;
|
||
const decoder = new TextDecoder();
|
||
let buf = '';
|
||
|
||
const read = () => {
|
||
reader.read().then(({ done, value }) => {
|
||
if (done) { setTimeout(reconnect, 3000); return; }
|
||
buf += decoder.decode(value, { stream: true });
|
||
const lines = buf.split('\n');
|
||
buf = lines.pop() || '';
|
||
let evt = '', data = '';
|
||
for (const line of lines) {
|
||
if (line.startsWith('event: ')) evt = line.slice(7).trim();
|
||
else if (line.startsWith('data: ')) data = line.slice(6);
|
||
else if (line === '' && evt) {
|
||
try { onEvent(evt, JSON.parse(data)); } catch {}
|
||
evt = ''; data = '';
|
||
}
|
||
}
|
||
read();
|
||
}).catch(() => setTimeout(reconnect, 5000));
|
||
};
|
||
read();
|
||
}).catch(() => setTimeout(reconnect, 5000));
|
||
};
|
||
|
||
reconnect();
|
||
}
|
||
|
||
// ─── 注册模型工具 ───
|
||
|
||
ctx.effect(() => {
|
||
// send_mail
|
||
ctx.tools.register(defineTool({
|
||
name: 'send_mail',
|
||
description: '发送邮件。三维地址 name@path.session:省略 session 投递到默认会话,.new 强制新建,.具体别名 必须已存在。回复来信请传 reply_to。',
|
||
parameters: {
|
||
to: { type: 'string', required: true, description: '收件人三维地址' },
|
||
subject: { type: 'string', required: true, description: '邮件主题' },
|
||
body: { type: 'string', required: true, description: '邮件正文(Markdown)' },
|
||
cc: { type: 'string', description: '抄送,逗号分隔多个三维地址' },
|
||
reply_to: { type: 'string', description: '回复某封邮件时传其 mail_id' },
|
||
session_alias: { type: 'string', description: '给新会话命名' },
|
||
attachment_ids: { type: 'array', items: { type: 'string' }, description: '附件 ID 列表' },
|
||
propose_alias: {
|
||
type: 'string',
|
||
description: '建议把当前会话改名成这个别名(例如摸清问题后从「排查登录问题」改成 fix-session-cookie-leak)。这只是建议:别名是人的寻址入口,实际改名由用户在界面上确认。不可含 . / @ 空白,不可为 new',
|
||
},
|
||
propose_reason: { type: 'string', description: '改名理由,一句话,展示给用户看' },
|
||
},
|
||
output: {
|
||
schema: { type: 'string' },
|
||
render: (_args: any, value: string) => [{ type: 'text', text: value }],
|
||
},
|
||
async execute(args: any, toolCtx: any): Promise<string> {
|
||
// 改名提议以 HTML 注释形式附在正文末尾,由网关解析后剥离。
|
||
// 拼接收在 lib/rename-proposal.js(三平台共用):标记格式是服务端正则的
|
||
// 镜像,各写一遍的话少个空格就静默失效 —— 邮件照常发出,提议凭空消失。
|
||
const { body, proposed } = appendRenameProposal(
|
||
args.body, args.propose_alias, args.propose_reason);
|
||
const result = await client.post('/mail/send', {
|
||
to: args.to, subject: args.subject, body,
|
||
cc: args.cc || '', reply_to: args.reply_to || '',
|
||
session_alias: args.session_alias || '',
|
||
attachment_ids: args.attachment_ids || [],
|
||
from_session_id: toolCtx?.sessionID || '',
|
||
});
|
||
noteExplicitSend(toolCtx?.sessionID, args.to, args.reply_to);
|
||
const budget = typeof result.budget_remaining === 'number'
|
||
? ` 本任务剩余 ${result.budget_remaining}/${result.budget_max} 个来回。` : '';
|
||
// 别名取服务端回的 rename_proposed(它跑过 normalizeAlias),
|
||
// 回显本地值会让模型记住一个不存在的名字,之后拿它寻址就 404
|
||
const note = renameProposalNote(result.rename_proposed, args.propose_alias, proposed);
|
||
return `邮件已发送(ID: ${result.mail_id})${budget}` + (note ? `\n${note}` : '');
|
||
},
|
||
}));
|
||
|
||
// read_inbox
|
||
ctx.tools.register(defineTool({
|
||
name: 'read_inbox',
|
||
description: '查阅收件箱中的邮件。收到新邮件通知后应立即调用此工具。每封含 mail_id、发件人、主题、正文与附件清单(带 attachment_id)。',
|
||
parameters: {
|
||
status: { type: 'string', description: '过滤条件 unread|all,默认 unread' },
|
||
limit: { type: 'number', description: '返回数量,默认 5' },
|
||
},
|
||
output: {
|
||
schema: { type: 'string' },
|
||
render: (_args: any, value: string) => [{ type: 'text', text: value }],
|
||
},
|
||
async execute(args: any): Promise<string> {
|
||
const status = args.status || DEFAULT_INBOX_STATUS;
|
||
const { mails } = await client.get(
|
||
`/mail/inbox?status=${status}&limit=${args.limit || DEFAULT_INBOX_LIMIT}`
|
||
);
|
||
|
||
// 渲染与已读策略放 lib/inbox-format.js:它们与平台 SDK 无关,
|
||
// 各平台插件必须一致(见该文件里每条规则对应的错误行为)。
|
||
//
|
||
// 传 AGENT_NAME 才能判定「我是收件人还是抄送方」并给出可投递地址。
|
||
const listed = renderInbox(mails, 200, AGENT_NAME);
|
||
|
||
// 读过就标掉,否则每次拉收件箱都重复捞同一批,
|
||
// 处理过的和新来的混在一起,模型分不清哪封该回。
|
||
const ids = idsToMarkRead(args.status, mails);
|
||
if (ids.length) {
|
||
// 标记失败不该让 read_inbox 失败:正文已经取到了,
|
||
// 代价只是下次重复看到,比丢掉这次读取轻。
|
||
client.post('/mail/read', { mail_ids: ids }).catch((e: any) =>
|
||
ctx.logger.error(`[dsh-mail-bridge] 标记已读失败: ${e?.message || e}`));
|
||
}
|
||
return listed;
|
||
},
|
||
}));
|
||
|
||
// upload_attachment
|
||
ctx.tools.register(defineTool({
|
||
name: 'upload_attachment',
|
||
description:
|
||
'上传本地文件作为邮件附件,返回 attachment_id。' +
|
||
'拿到 id 后必须在 send_mail 的 attachment_ids 里带上,附件才会随邮件发出。' +
|
||
'未随邮件发出的附件 24 小时后自动清理。',
|
||
parameters: {
|
||
file_path: { type: 'string', required: true, description: '要上传的本地文件绝对路径' },
|
||
filename: { type: 'string', description: '自定义展示文件名,默认取路径的最后一段' },
|
||
},
|
||
output: {
|
||
schema: { type: 'string' },
|
||
render: (_args: any, value: string) => [{ type: 'text', text: value }],
|
||
},
|
||
async execute(args: any): Promise<string> {
|
||
// 先 stat 再读:目录和不存在的路径都要给出能行动的错误。
|
||
// 直接 readFile 的话,目录抛的 EISDIR 只会让模型重试同一个路径。
|
||
let st;
|
||
try {
|
||
st = await stat(args.file_path);
|
||
} catch {
|
||
return `文件不存在或不可读: ${args.file_path}`;
|
||
}
|
||
if (!st.isFile()) return `不是普通文件: ${args.file_path}`;
|
||
|
||
const data = await readFile(args.file_path);
|
||
const filename = args.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(),
|
||
body: form,
|
||
});
|
||
const json = await res.json() as any;
|
||
if (!res.ok) throw new Error(json?.error || `HTTP ${res.status}`);
|
||
const a = json.attachment;
|
||
return `已上传 ${a.filename}(${formatSize(a.size_bytes)})。attachment_id: ${a.attachment_id}\n` +
|
||
`在 send_mail 的 attachment_ids 里带上这个 id 才会随邮件发出。`;
|
||
},
|
||
}));
|
||
|
||
// download_attachment
|
||
ctx.tools.register(defineTool({
|
||
name: 'download_attachment',
|
||
description: '下载邮件附件到本地文件。attachment_id 从 read_inbox 的附件清单里取。',
|
||
parameters: {
|
||
attachment_id: { type: 'string', required: true, description: '附件 ID' },
|
||
save_path: { type: 'string', required: true, description: '保存到的本地绝对路径' },
|
||
},
|
||
output: {
|
||
schema: { type: 'string' },
|
||
render: (_args: any, value: string) => [{ type: 'text', text: value }],
|
||
},
|
||
async execute(args: any): Promise<string> {
|
||
const res = await fetch(`${client.baseURL}/api/v1/attachments/${args.attachment_id}`, {
|
||
headers: client.authHeaders(),
|
||
});
|
||
if (!res.ok) throw new Error(`下载失败: HTTP ${res.status}`);
|
||
const buf = Buffer.from(await res.arrayBuffer());
|
||
// 父目录不存在时先建:模型经常写 ./downloads/x.pdf 这类还不存在的路径,
|
||
// 不建的话 writeFile 抛 ENOENT,而那个错误看起来像「附件不存在」。
|
||
await mkdir(dirname(args.save_path), { recursive: true });
|
||
await writeFile(args.save_path, buf);
|
||
return `已保存到 ${args.save_path}(${formatSize(buf.length)})`;
|
||
},
|
||
}));
|
||
|
||
// ─── 寻址发现工具(读 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}`;
|
||
},
|
||
}));
|
||
|
||
// connect_to_server —— 连接自愈。
|
||
//
|
||
// 之前只有 opencode 侧有这个工具。后果是:Gateway 换了地址、或密钥需要
|
||
// 重新登记时,opencode 里的模型能自己修好,而 DSH 里的模型只能干等
|
||
// systemd 环境变量被人改 —— 同一类能力在不同平台上时有时无,
|
||
// 等于让人记住哪个平台能自己修。
|
||
//
|
||
// 失败时**把需要登记的密钥全文打出来**:密钥未登记是最常见的失败,
|
||
// 不给值的话要多走一轮「密钥无效 → 去哪拿 → 让管理员登记」。
|
||
ctx.tools.register(defineTool({
|
||
name: 'connect_to_server',
|
||
description:
|
||
'连接到 AgentMail Gateway:登记本机密钥并完成注册。首次安装或换了 Gateway 地址时调用。' +
|
||
'密钥若未在后台登记过,此处会返回需要登记的密钥全文。',
|
||
parameters: {
|
||
gateway_url: { type: 'string', description: 'Gateway 地址,如 https://mail.example.com;省略则用当前配置' },
|
||
key_token: { type: 'string', description: '管理员签发的 Agent 密钥;省略则用本地密钥(不存在时自动生成)' },
|
||
},
|
||
output: {
|
||
schema: { type: 'string' },
|
||
render: (_args: any, value: string) => [{ type: 'text', text: value }],
|
||
},
|
||
async execute(args: any): Promise<string> {
|
||
let key = client.agentKey;
|
||
if (args.key_token) {
|
||
key = String(args.key_token).trim();
|
||
// 管理员给的密钥落盘,重启后仍然可用
|
||
saveLocalKey(key);
|
||
} else if (!key) {
|
||
key = generateLocalKey();
|
||
}
|
||
|
||
const url = String(args.gateway_url || client.baseURL).replace(/\/+$/, '');
|
||
|
||
const res = await fetch(`${url}/api/v1/agent/register`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
|
||
body: JSON.stringify({ name: AGENT_NAME, workspaces: [], platform: 'dsh' }),
|
||
});
|
||
const data = await res.json().catch(() => ({})) as any;
|
||
|
||
if (!res.ok) {
|
||
return [
|
||
`连接失败(HTTP ${res.status}):${data?.error || '未知错误'}`,
|
||
``,
|
||
`若提示密钥无效,请让管理员在 AgentMail 后台「Agent 密钥」中登记:`,
|
||
key,
|
||
``,
|
||
`密钥文件:${KEY_FILE}`,
|
||
].join('\n');
|
||
}
|
||
|
||
// 成功后把新坐标写回客户端,当场生效(不用等重启)
|
||
client.baseURL = url;
|
||
client.agentKey = key;
|
||
return `已连接 ${url},注册为 ${data?.agent_name || AGENT_NAME}。`;
|
||
},
|
||
}));
|
||
|
||
return () => {
|
||
for (const n of [
|
||
'send_mail', 'read_inbox', 'read_mail', 'forward_mail',
|
||
'upload_attachment', 'download_attachment',
|
||
'suggest_address', 'list_contacts', 'session_participants', 'read_thread',
|
||
'connect_to_server',
|
||
]) {
|
||
try { ctx.tools.unregister(n); } catch {}
|
||
}
|
||
};
|
||
}, 'dsh-mail-bridge.tools');
|
||
|
||
// ─── turn 完成后自动转发回复 ───
|
||
//
|
||
// 与 opencode-mail-bridge 的 session.idle 同一职责:模型把话说完了,
|
||
// 插件把它最后那段话搬到邮件里 —— 不该让模型自己记得调 send_mail。
|
||
ctx.on('agent/status', async (payload: any) => {
|
||
if (payload?.status !== 'idle') return;
|
||
const agent = payload.agent;
|
||
if (!agent?.id) return;
|
||
const mailSessionID = reverseMap.get(String(agent.id));
|
||
if (!mailSessionID) return;
|
||
const mctx = mailContexts.get(mailSessionID);
|
||
|
||
// **只给人类来信自动转发**(见 lib/relay-policy.js)。
|
||
//
|
||
// 对方是 Agent 时它那边的插件也会自动回一封,于是两个模型都以为「我只要
|
||
// 把话说完就行」,实际在持续互相唤醒 —— 生产实测 pi 与 dsh 客套 6 轮直到
|
||
// 撞上连续 relay 跳数上限。日志走 console.error:ctx.logger 不进 journalctl,
|
||
// 而「本轮为何没有回信」必须能查到。
|
||
const policy = autoRelayDecision({
|
||
fromHuman: mctx?.fromHuman === true,
|
||
replyTo: mctx?.replyTo,
|
||
});
|
||
if (!policy.relay) {
|
||
console.error(`[dsh-mail-bridge] 本轮不自动转发:${policy.reason}`);
|
||
return;
|
||
}
|
||
// policy.relay 为真已经蕴含 mctx?.replyTo 非空(autoRelayDecision 的第一道判定),
|
||
// 但 TS 推不出那层关系 —— 显式窄一下,而不是给后面每处加非空断言。
|
||
if (!mctx) return;
|
||
|
||
// 取最后一条 assistant 消息的文本
|
||
const events = agent.session?.events ?? [];
|
||
const lastText = lastAssistantText(events);
|
||
if (!lastText) return;
|
||
|
||
// 本轮模型已亲手回过这条线索 → 不再自动转发(与 opencode 侧同一取舍)
|
||
if (shouldSkipAutoRelay(explicitSends.get(String(agent.id)), mctx.replyTo, mctx.mailID)) {
|
||
explicitSends.delete(String(agent.id));
|
||
return;
|
||
}
|
||
// 同一条消息只转一次
|
||
if (relayedSummaries.get(String(agent.id)) === lastText) return;
|
||
|
||
try {
|
||
await client.post('/mail/send', {
|
||
to: mctx.replyTo,
|
||
subject: replySubject(mctx.subject),
|
||
body: lastText,
|
||
reply_to: mctx.mailID || '',
|
||
// relay + relay_key:走免配额通道(harness 的搬运不该收费)
|
||
relay: 'summary',
|
||
relay_key: clampRelayKey(`${agent.id}:${events.length}`),
|
||
});
|
||
relayedSummaries.set(String(agent.id), lastText);
|
||
explicitSends.delete(String(agent.id));
|
||
ctx.logger.info(`[dsh-mail-bridge] 总结已回信 ${mctx.replyTo}(不计配额)`);
|
||
} catch (e: any) {
|
||
ctx.logger.error(`[dsh-mail-bridge] 转发回复失败: ${e?.message || e}`);
|
||
}
|
||
|
||
// 会话命名回写:DSH 首轮结束后由模型生成摘要标题,把它同步回 AgentMail。
|
||
// 与 opencode 侧同一决定:不另造一套命名,平台叫什么这边就叫什么。
|
||
//
|
||
// DSH 没有 opencode 那样的 slug,别名由标题派生(slugFromTitle 会去掉
|
||
// `.` `@` `/` 这些寻址分隔符 —— 留在别名里会让它自己被解析器切开)。
|
||
// 别名与标题一起发:服务端撞名时自动追 -2/-3 后缀,并尊重人工改过的别名。
|
||
const title = modelTitle(events);
|
||
if (title && syncedTitles.get(String(agent.id)) !== title) {
|
||
syncedTitles.set(String(agent.id), title);
|
||
const alias = slugFromTitle(title);
|
||
try {
|
||
await client.post(`/sessions/${mailSessionID}/sync`,
|
||
alias ? { title, alias } : { title });
|
||
ctx.logger.info(
|
||
`[dsh-mail-bridge] 会话命名已同步: title=${title}${alias ? ` alias=${alias}` : ''}`);
|
||
} catch (e: any) {
|
||
ctx.logger.error(`[dsh-mail-bridge] 命名同步失败: ${e?.message || e}`);
|
||
}
|
||
}
|
||
});
|
||
|
||
// ─── 平台原生权限询问 → 转成邮件问人 ───
|
||
//
|
||
// 这是 harness 的职责,不该让模型自己调一个 request_permission 工具:
|
||
// 模型可能忘了调,也可能在不需要时乱调,而真正被 DSH 拦下的那次询问反而没人看见。
|
||
//
|
||
// 与 opencode 的差异:opencode 的 permission.ask 是同步钩子,卡在里面会把整个
|
||
// 请求挂住,所以那边只能“转出去就返回 ask”;而 DSH 的 approval/request 是
|
||
// **异步 waterfall**,返回 Promise<ApprovalOutcome> 就是它要的,因此可以真的等人。
|
||
ctx.on('approval/request', async (req: any, next: () => Promise<string>) => {
|
||
const agentId = String(req?.agent?.id ?? '');
|
||
if (!mailDrivenSessions.has(agentId)) return next(); // 非邮件驱动的会话不接管
|
||
const mailSessionID = reverseMap.get(agentId);
|
||
if (!mailSessionID) return next();
|
||
|
||
// DSH 不给询问发 id,用 (会话, 工具, callId) 做幂等键。
|
||
// clampRelayKey 收尾:callId 的长度由上游模型决定,pi 侧实测过带思考签名的
|
||
// 13601 字节 id,超服务端 160 字节列宽直接 400。
|
||
const relayKey = clampRelayKey(`${agentId}:${req.toolName}:${req.callId ?? 'nocall'}`);
|
||
const mctx = mailContexts.get(mailSessionID);
|
||
|
||
try {
|
||
// **不传 `to`**:决策人由服务端定(会话 owner → 线索里最近的人类 →
|
||
// 无人可问则 409)。插件若把来信人当决策人,Agent 之间转派任务时
|
||
// (A 把活分给 B)权限邮件会发给 Agent 自己 —— Agent 不可能在界面上点
|
||
// 「同意」,于是下面那个 await 永不 resolve,会话无声挂死。
|
||
await client.post('/permission/request', {
|
||
question: `请求执行 ${req.toolName}`,
|
||
options: ['同意', '拒绝'],
|
||
context: [
|
||
`工具:${req.toolName}`,
|
||
req.callId ? `调用 ID:${req.callId}` : '',
|
||
req.reason ? `理由:${req.reason}` : '',
|
||
// 决策人未必是这条会话的参与者(Agent 转派出来的会话,人从没见过它),
|
||
// 只给工具名无从判断,得说明这活是谁派的、为的什么事(B-8.4)。
|
||
mctx?.subject ? `触发任务:${mctx.subject}` : '',
|
||
mctx?.replyTo ? `任务来自:${mctx.replyTo}` : '',
|
||
].filter(Boolean).join('\n'),
|
||
session_id: mailSessionID,
|
||
relay_key: relayKey,
|
||
});
|
||
} catch (e: any) {
|
||
// 409 = 服务端已判定这条任务链上没有人类,永远不会有人来点头。
|
||
//
|
||
// 不能 `return next()`:下一个 answerer 是本地 UI,而邮件驱动的会话
|
||
// 根本没有 UI,waterfall 跑到尾以后依旧无人应答 —— 这正是生产事故
|
||
// 的形状:pi 把任务派给自己的另一条会话,那条要跑 bash,会话永久挂死。
|
||
//
|
||
// 直接 denied 并把服务端的建议原文写进日志:模型从工具报错里看到
|
||
// 拒绝后会自己换方式,而挂死时它连重试的机会都没有。
|
||
if (e?.status === 409) {
|
||
const hint = [e?.body?.error, e?.body?.detail, e?.body?.suggestion]
|
||
.filter(Boolean).join(' ');
|
||
console.error(`[dsh-mail-bridge] 权限询问无人可投,当场拒绝 ${relayKey}:${hint}`);
|
||
// 把真正的原因存起来,post-execute 会用它换掉 dsh-tools 写死的
|
||
// 「the user rejected tool X」—— 没有任何用户拒绝过它。
|
||
noteDenial(agentId, req.callId, [
|
||
e?.body?.error || `权限询问无法送达:这条任务链上没有人类用户`,
|
||
e?.body?.detail || '',
|
||
e?.body?.suggestion || '',
|
||
].filter(Boolean).join('\n'));
|
||
// 用 'rejected' 而不是 'denied':DSH 的 ApprovalOutcome 只认
|
||
// allowed-once / rejected / cancelled / unavailable,写错了它不报错
|
||
// 而是归一化成 'unavailable'。
|
||
return 'rejected';
|
||
}
|
||
|
||
// 其余 4xx(400 / 401 / 403 / 404 / 422…)同样永远不会因重试成功,
|
||
// 不能 `return next()` —— 下一个 answerer 是本地 UI,而邮件驱动的会话
|
||
// 没有 UI,waterfall 跑到尾仍旧无人应答。
|
||
// pi 侧实测:relay_key 过长报 400 被当暂时失败让位,
|
||
// 那条 bash 在无人批准的情况下执行了 —— fail closed 才安全。
|
||
if (isPermanentFailure(e)) {
|
||
const hint = [e?.body?.error, e?.body?.detail, e?.body?.suggestion]
|
||
.filter(Boolean).join(' ');
|
||
console.error(
|
||
`[dsh-mail-bridge] 权限询问遇到永久失败(HTTP ${e?.status}),当场拒绝 ${relayKey}:${hint || e?.message || ''}`,
|
||
);
|
||
noteDenial(agentId, req.callId, [
|
||
`无法把 ${req.toolName} 的授权请求送达给人类(HTTP ${e?.status}):${e?.body?.error || e?.message || '请求被服务端拒绝'}`,
|
||
e?.body?.detail || '',
|
||
e?.body?.suggestion || '这是一个不会因重试而改变的失败。请改用不需要授权的方式完成,或在回信里说明需要人工执行哪一步。',
|
||
].filter(Boolean).join('\n'));
|
||
return 'rejected';
|
||
}
|
||
|
||
// 暂时失败(5xx / 408 / 429 / 网络抖动)交给下一个 answerer(本地 UI)。
|
||
console.error(`[dsh-mail-bridge] 权限询问转发暂时失败: ${e?.message || e}`);
|
||
return next();
|
||
}
|
||
|
||
console.error(`[dsh-mail-bridge] 权限询问已转邮件 ${relayKey}`);
|
||
|
||
// 等人类决策;DSH 撤销询问(signal abort)时结算为 cancelled。
|
||
return new Promise<string>((resolve) => {
|
||
pendingApprovals.set(relayKey, { resolve, sessionId: agentId });
|
||
req.signal?.addEventListener('abort', () => {
|
||
if (pendingApprovals.delete(relayKey)) resolve('cancelled');
|
||
}, { once: true });
|
||
});
|
||
});
|
||
|
||
// 把插件主动拒绝的真正原因递给模型。
|
||
//
|
||
// DSH 将 approval/request 的 'rejected' 翻译成写死的
|
||
// `the user rejected tool "X"`(dsh-tools/lib/index.js)—— 而当插件因为
|
||
// 「没人可问」或「转发遇 4xx」主动拒绝时,**没有任何用户拒绝过它**。
|
||
// 模型看到一句不存在的拒绝,只会以为人不同意,不会去换一条路;
|
||
// 服务端给的 suggestion(换不需要权限的方式 / 在回信里请上游转达)则只进了日志。
|
||
//
|
||
// 门禁拒绝的调用也会进 post-execute(pre-execute 的 deny 走
|
||
// `{kind:"post-result"}` → finalizeScheduledExecution → postExecute),
|
||
// 而 `{kind:'block', feedback}` 能换掉模型看到的内容 —— 这是 DSH 上
|
||
// 唯一能把真实原因送到模型眼前的口子。
|
||
//
|
||
// pi(`{block:true, reason}`)与 opencode(`output.reason`)的 reason 直达模型,
|
||
// 不需要这道绕行。
|
||
ctx.on('tools/post-execute', async (exec: any, result: any, next: () => Promise<any>) => {
|
||
const agentId = String(exec?.agent?.id ?? '');
|
||
if (!agentId || !mailDrivenSessions.has(agentId)) return next();
|
||
// 只管失败的结果:成功的调用不可能是被我们拒绝的那一次。
|
||
if (!result?.isError) return next();
|
||
|
||
const reason = takeDenial(agentId, exec?.callId);
|
||
if (!reason) return next();
|
||
|
||
console.error(`[dsh-mail-bridge] 已把拒绝原因递给模型(${exec?.name})`);
|
||
return {
|
||
kind: 'block',
|
||
feedback: [{
|
||
type: 'text',
|
||
text: `无法执行 ${exec?.name}:${reason}`,
|
||
}],
|
||
};
|
||
});
|
||
|
||
/** 人类决策回来:先看是不是在等的那条 approval,否则当普通通知投给会话。 */
|
||
function handlePermissionDecision(data: any): void {
|
||
const relayKey = String(data?.relay_key ?? '');
|
||
const pending = relayKey ? pendingApprovals.get(relayKey) : undefined;
|
||
if (pending) {
|
||
pendingApprovals.delete(relayKey);
|
||
// AgentMail 的选项文本 → DSH 的 ApprovalOutcome。
|
||
// 只有“同意”才放行,其余(包括认不出的选项)一律 fail closed。
|
||
//
|
||
// **DSH 不提供「一直同意」**:它的 ApprovalOutcome 只有
|
||
// allowed-once / rejected / cancelled / unavailable,没有 always 语义
|
||
// (见 @deepseek-ai/dsh-user-approval 的类型定义)。桥自己记免批的话,
|
||
// DSH 侧仍会每次调 approval/request,而桥直接答 allowed-once ——
|
||
// 那等于用插件内存覆盖平台的审批策略,且这份策略没人能审计。
|
||
// 因此这里的选项只有两个(见上面的 options),isApproval 就够用。
|
||
const decision = String(data?.decision ?? '');
|
||
const outcome = isApproval(decision) ? 'allowed-once' : 'rejected';
|
||
pending.resolve(outcome);
|
||
ctx.logger.info(`[dsh-mail-bridge] 权限决策 ${relayKey} -> ${outcome}`);
|
||
return;
|
||
}
|
||
// 没在等(插件重启后丢了 pendingApprovals,或历史数据)→ 当一封通知投进会话。
|
||
deliverMail(data, 'permission')
|
||
.catch((e: any) => ctx.logger.error(`[dsh-mail-bridge] 权限决策投递失败: ${e?.message || e}`));
|
||
}
|
||
|
||
// ─── 启动 SSE(与 opencode-mail-bridge 相同的事件处理)───
|
||
|
||
ctx.effect(() => {
|
||
startSSE((type, data) => {
|
||
switch (type) {
|
||
case 'new_mail':
|
||
if (data?.mail_id) deliveredMails.add(data.mail_id);
|
||
deliverMail(data, 'mail')
|
||
.then(({ sessionID, reused }) => {
|
||
console.error(`[dsh-mail-bridge] ${type} -> ${reused ? '续谈' : '新会话'} ${sessionID}`);
|
||
})
|
||
.catch((e: any) => {
|
||
// console.error 而不是 ctx.logger:DSH 的 logger 不进 journalctl,
|
||
// 而投递失败是「发件人等不到回信」的唯一线索。
|
||
console.error(`[dsh-mail-bridge] ${type} 处理失败(mail ${data?.mail_id || '?'}): ${e?.message || e}`);
|
||
});
|
||
break;
|
||
case 'permission_decision':
|
||
handlePermissionDecision(data);
|
||
break;
|
||
case 'session_archived':
|
||
// 会话归档 = 那条会话再也不会收信,映射可以确定性地清掉(不必等上限淘汰)。
|
||
forgetSession(String(data?.session_id || ''));
|
||
break;
|
||
}
|
||
});
|
||
return () => {
|
||
sseAbort?.abort();
|
||
sseAbort = null;
|
||
// 拆插件时没人再能回答待决询问,一律 fail closed,
|
||
// 否则 DSH 侧那些 await 永远不会返回。
|
||
for (const [key, pending] of pendingApprovals) {
|
||
pending.resolve('unavailable');
|
||
pendingApprovals.delete(key);
|
||
}
|
||
};
|
||
}, 'dsh-mail-bridge.sse');
|
||
}
|