feat: 每平台可用模型范围 + 降级尝试 + 失败回报
配置页为每个 Agent 平台划定「邮件场景下可用的模型」,插件按顺序逐个尝试,
全部失败把原因封装成邮件回复。目录由插件上报、管理员只做勾选 —— 手打模型名
会打错,而打错的后果要到真发邮件时才暴露成一次失败。
## 目录上报走心跳,不另设端点
模型清单会在运行中变(换 provider 配置、上游上下线、换 API key)。
只在注册时报一次的话目录会静静变陈,管理员在配置页选中一个平台其实调不到的
模型。心跳本来就是 30 秒一次的现成通道;另设一个 POST 等于给「目录是谁写的」
留两个答案,排查时要同时看两处。
心跳响应回传 `allowed_models`,因此管理员改了范围后最多一个周期生效,
不必重启插件。
与 platform_sessions 同一约定:拉不到目录时**省略字段**(保留现有目录),
传空数组会把配置页清成空白。
## 目录与选择分两张表
模型会从平台目录里消失(上游临时下线、换了 provider 配置)。合成一张带
allowed 标记的表时,整行被删就连带把管理员的选择也删了,模型回来还得重配一遍。
分开存之后「选了什么」是持久的,目录只决定「这一项现在是否可用」;
已选但不在目录里的标为 stale 显示出来 —— 不显示会让人以为自己没选过它。
## 最难的一点:模型失败不是同步抛出的
两个平台都踩了。`promptAsync()` 立即返回、`ctx.agents.create()` 不校验模型,
只包 try/catch 的话第二个模型永远不会被试到 —— 第一个无效模型会被判成成功。
必须等异步结论:
- opencode → `session.error` 事件(event 钩子在 deliverMail 之外,
因此用 turnWatchers 表把两者接起来)
- DSH → `turn/end` 的 `reason.kind === 'error'`
DSH 还有个陷阱:**`assistant/chunk` 不能当成功信号**,它的 `finish` 子类型
也带错误 —— `{chunk:{type:'finish',reason:{kind:'error',failure:{code:'NO_ADAPTER'}}}}`。
实测「无效 provider 却判成功」正是因为把任意 chunk 当成了走通。判据要落在
chunk 的类型上:finish 看 reason,其余才意味着模型真的在产出。
超时按成功处理(60 秒窗口):模型可能只是很慢,把慢当成失败会在换模型的同时
把已经在跑的那一轮丢掉。
DSH 换模型要换会话 id(`<原 id>-r1`)并 dispose 失败那个 agent:复用同一个 id
会让重试接在一条已经出错的会话后面,不 dispose 则 agent/status 还会为那个
死会话触发一次自动转发。
## 其他决策
- **范围优先于环境变量**:范围是运行时可改的策略,`AGENTMAIL_REPLY_*` 是部署时
的兜底。反过来的话管理员在配置页改了却不生效,得去改 service 文件重启
- **范围为空返回 `[undefined]` 而非 `[]`**:空数组会让调用方一次都不试,
而「管理员没配」的正确含义是不限定,不是「一个都不许用」
- **上限 10 个**:降级是串行的,选 50 个意味着最坏情况下一封邮件要等 50 次超时
- 前端 key 按**第一个** `/` 切分 provider/model:model id 可能含 `/`
(如 `org/model-name`),按最后一个切会把 provider 切错
- 保存后用服务端返回的结果刷新界面而非回显入参:repo 层会跳过重复与空字段
## 验证
- Go 10 个新测试(含「模型从目录消失后选择必须留存」的直接回归)
- 两插件各 18 个模型范围测试,共 180 个
- 端到端四轮:正常路由 → 全部无效(收到失败回报邮件,used_rounds 保持 0
确认走了免配额通道)→ DSH 降级(fake-a 失败 → llmsproxy/AUTO 成功)→
opencode 降级(nonexistent/bad 失败 → AUTO 成功,日志确认「前 1 个失败」)
- 生产已部署,前端「模型范围」页可用
This commit is contained in:
@ -28,6 +28,11 @@ import {
|
||||
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 {
|
||||
renderInbox,
|
||||
@ -121,6 +126,10 @@ const reverseMap = new Map<string, string>();
|
||||
const mailDrivenSessions = new Set<string>();
|
||||
const mailContexts = new Map<string, { replyTo: string; subject: string; mailID: string }>();
|
||||
const relayedSummaries = new Map<string, string>();
|
||||
|
||||
// 管理员在配置页划定的可用模型范围(按优先级)。随心跳响应更新。
|
||||
// 空数组 = 不限定,回退到环境变量或平台默认。
|
||||
let allowedModels: { provider: string; model: string }[] = [];
|
||||
const syncedTitles = new Map<string, string>();
|
||||
|
||||
// 权限询问:DSH 的 approval/request 是 waterfall 钩子,插件把它转成邮件问人,
|
||||
@ -259,15 +268,17 @@ export function apply(ctx: any, config: PluginConfig): void {
|
||||
}
|
||||
|
||||
async function beat(): Promise<void> {
|
||||
let body: Record<string, unknown> = {};
|
||||
const entries = await collectSessions();
|
||||
const body: Record<string, unknown> = {};
|
||||
const [entries, models] = await Promise.all([collectSessions(), collectModels()]);
|
||||
if (entries) {
|
||||
body = {
|
||||
platform_sessions: snapshotDshSessions(entries, (id) => mailDrivenSessions.has(id)),
|
||||
};
|
||||
body.platform_sessions = snapshotDshSessions(entries, (id) => mailDrivenSessions.has(id));
|
||||
}
|
||||
if (models) body.models = models;
|
||||
try {
|
||||
await client.post('/agent/heartbeat', body);
|
||||
const res = await client.post('/agent/heartbeat', body);
|
||||
// 生效的模型范围随心跳响应回传:管理员在配置页改了范围后,
|
||||
// 插件最多一个周期(30 秒)就能看到新值,不需要重启。
|
||||
if (Array.isArray(res?.allowed_models)) allowedModels = res.allowed_models;
|
||||
} catch {
|
||||
// 心跳失败不报错:网络抖动很常见,下一轮会补上。
|
||||
// 真的持续连不上时 Gateway 会把它判成离线,那才是可见的信号。
|
||||
@ -282,16 +293,110 @@ export function apply(ctx: any, config: PluginConfig): void {
|
||||
|
||||
// ─── 获取默认模型 ───
|
||||
|
||||
function modelSelection(): { provider: string; model: string } | undefined {
|
||||
if (REPLY_PROVIDER && REPLY_MODEL) {
|
||||
return { provider: REPLY_PROVIDER, model: REPLY_MODEL };
|
||||
/**
|
||||
* 这一轮按什么顺序尝试模型。
|
||||
*
|
||||
* 优先级:管理员划定的范围 > 环境变量指定 > 平台自己的默认选择。
|
||||
* 范围是运行时可改的策略,环境变量是部署时的兜底,因此前者优先。
|
||||
*/
|
||||
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;
|
||||
}
|
||||
const defaults = ctx.get('agentDefaultModel');
|
||||
const sel = defaults?.currentSelection?.();
|
||||
if (sel?.provider && sel.model) {
|
||||
return { provider: sel.provider, model: sel.model };
|
||||
}
|
||||
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') {
|
||||
// finish 是这一步的收尾,可能成功也可能失败
|
||||
if (chunk.reason?.kind === 'error') {
|
||||
return finish({ ok: false, error: describe(chunk.reason.failure) });
|
||||
}
|
||||
return; // 正常收尾,等 turn/end 定论
|
||||
}
|
||||
// 其余 chunk 类型 = 模型真的在产出内容
|
||||
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) });
|
||||
}
|
||||
// 正常结束(completed/canceled)也算走通了 —— 有些轮次不产出 chunk
|
||||
return finish({ ok: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 投递邮件到 DSH 会话 ───
|
||||
@ -335,11 +440,6 @@ export function apply(ctx: any, config: PluginConfig): void {
|
||||
`[dsh-mail-bridge] 工作目录 ${data.to_workspace} 不可用,回退到 ${cwd}`);
|
||||
}
|
||||
|
||||
const selection = modelSelection();
|
||||
const agentOpts = selection
|
||||
? { provider: selection.provider, model: selection.model }
|
||||
: {};
|
||||
|
||||
const promptText = kind === 'permission'
|
||||
? `你之前发起的权限请求已有结论:${data.decision}(决策人:${data.decided_by || '用户'})。请据此继续。`
|
||||
: [
|
||||
@ -357,29 +457,100 @@ export function apply(ctx: any, config: PluginConfig): void {
|
||||
`只有在需要主动联系其他人、或要带附件时才调用 send_mail。`,
|
||||
].join('\n');
|
||||
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId,
|
||||
meta: { cwd },
|
||||
agentOptions: agentOpts,
|
||||
// setup 留空:DSH 的 base bundle 已经注册了 agent-loop、llm、tools 等服务。
|
||||
// modelSelection 通过 agentOptions 传入即可 —— 挂载 preset 或
|
||||
// installModelSelection 反而会让 turn 崩溃(实测)。
|
||||
setup: undefined,
|
||||
});
|
||||
// 按管理员划定的范围逐个尝试建 agent,全部失败才回一封说明原因的邮件。
|
||||
//
|
||||
// 必须发那封信:agent 一次都没建起来时会话里没有任何 assistant 消息,
|
||||
// 自动转发因此什么也不会发 —— 发件人只会看到邮件发出去后再无音讯。
|
||||
//
|
||||
// DSH 与 opencode 的差异:这里失败在**建 agent** 时就暴露(模型路由是
|
||||
// agentOptions 的一部分),而 opencode 是在 promptAsync 时。
|
||||
const attempts = attemptOrder();
|
||||
const failures: { provider?: string; model?: string; error: string }[] = [];
|
||||
|
||||
if (mailSessionID) {
|
||||
sessionMap.set(mailSessionID, { dshSessionId: sessionId, directory: cwd });
|
||||
reverseMap.set(sessionId, mailSessionID);
|
||||
mailDrivenSessions.add(sessionId);
|
||||
mailContexts.set(mailSessionID, {
|
||||
replyTo: data.from_name || '',
|
||||
subject: data.subject || '',
|
||||
mailID: data.mail_id || '',
|
||||
});
|
||||
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 {
|
||||
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,
|
||||
});
|
||||
} catch (e: any) {
|
||||
// create 本身很少失败(它不校验模型),但会话 id 冲突之类仍会抛
|
||||
failures.push({ ...(route ?? {}), error: e?.message || String(e) });
|
||||
ctx.logger.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 || '',
|
||||
});
|
||||
}
|
||||
|
||||
const watching = awaitFirstTurn(handle.agent);
|
||||
handle.agent.followup(userMessage(promptText));
|
||||
const outcome = await watching;
|
||||
|
||||
if (outcome.ok) {
|
||||
if (failures.length > 0) {
|
||||
ctx.logger.info(`[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}`);
|
||||
// 拆掉这一路的 agent 与映射,否则它会占着会话 id,
|
||||
// 而 agent/status 还会为这个死会话触发一次自动转发
|
||||
reverseMap.delete(attemptSessionId);
|
||||
mailDrivenSessions.delete(attemptSessionId);
|
||||
try {
|
||||
await handle.dispose();
|
||||
} catch {
|
||||
// dispose 失败不影响换下一个模型
|
||||
}
|
||||
}
|
||||
if (mailSessionID) sessionMap.delete(mailSessionID);
|
||||
|
||||
handle.agent.followup(userMessage(promptText));
|
||||
return { sessionID: sessionId, reused: false };
|
||||
// 全部失败:把原因作为邮件回给发件人。走免配额通道 ——
|
||||
// 这是插件的故障报告,不是模型的自主发信。
|
||||
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: `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 模式)───
|
||||
|
||||
Reference in New Issue
Block a user