feat(question): DSH ask_user_question 桥接 + 前端问答面板 + 待办字段全路径透出

问题(P0):DSH 有两个独立的人机交互 seam —— approval/request(危险工具审批)
与 ask_user_question → ctx.userQuestions(模型主动提问)。原来只桥接了前者。
邮件驱动的会话没有本地 UI,而 ask() 的 provider 是 DSH host 注册的本地 UI 实现,
于是在那里等人点选永久等不到,那一轮工具调用**静默挂死**。

修法(不抢注全局 provider —— registerProvider 只允许一个活动实例,抢注会让
平台自己的界面失效):在 tools/execute around-dispatch 里只对**邮件驱动**的
会话接管 ask_user_question,其余原样 next()。失败一律当场报错而不是 next():
下一个 answerer 是本地 UI,邮件会话没有兜底 UI,放过去就是挂死。

- lib/user-question.js(三桥逐字节同源,14 例测试):DSH questions[] ↔ AgentMail
  单问题询问邮件的双向映射。多问题时把选项并集摊平、按 label 归属分配回各问题
  (label 认不出来就不猜测放行);无选项题走自由文本 custom。
- Gateway:kind=question 且无选项时**不再**回落「同意/拒绝」(那会让自由文本
  问题变成两个毫无意义的按钮);主题按类型区分「权限请求 / 需要回答」;
  推送 payload 带上 permission_kind / multi_select / options。
- mails.permission_kind / permission_multi_select 此前只存在于结构体与写入路径,
  五个读路径的 SELECT/Scan 都没带 —— 前端永远拿到空串,把提问渲染成批准/拒绝。
  container 修正五处并加 repo 测试(含反向验证:删掉任一处字段,测试即失败)。
- 前端 PermissionPanel:question 走「勾选 + 自由文本」,多选/单选、空回答禁止提交;
  approval 路径不变(回归测试覆盖)。

测试:opencode 316 / dsh 349 / pi 405 / 前端 185 / Go 全量 全绿。
This commit is contained in:
2026-09-11 10:44:18 +08:00
parent c401eb2da2
commit f91efd2d8d
16 changed files with 1548 additions and 19 deletions

View File

@ -62,6 +62,11 @@ import { appendRenameProposal, renameProposalNote } from '../lib/rename-proposal
import { createSSEClient } from '../lib/sse-client.js';
// 只用 isApprovalDSH 没有 always 语义,免批授权表在这里用不上(见决策处的注释)。
import { isApproval } from '../lib/permission-grants.js';
import {
flattenQuestions,
answersFromDecision,
isBlankAnswer,
} from '../lib/user-question.js';
// ─── 凭证管理 ───
@ -210,6 +215,21 @@ interface PendingApproval {
}
const pendingApprovals = new Map<string, PendingApproval>();
// 待决的 ask_user_question 询问。
//
// 与 pendingApprovals 分开:那一边回的是 DSH 的 ApprovalOutcome 枚举字符串,
// 这一边要把人类的回答还原成 DSH 的 `answers[]` 结构。
//
// 同样**不设上界**:静默淘汰一条会让 `ask_user_question` 永远挂死。
// 清理路径确定:决策到达 / 询问被 abort / 拆插件时 fail closed。
interface PendingQuestion {
resolve: (answers: { answers: Array<{ id: string; selected: string[]; custom?: string }> }) => void;
reject: (err: Error) => void;
questions: any[];
sessionId: string;
}
const pendingQuestions = new Map<string, PendingQuestion>();
// 权限被插件主动拒绝时的真正原因 —— 键是 `${agentId}:${callId}`。
//
// 为什么需要这张表DSH 把 `approval/request` 的返回值翻译成模型可见文本时
@ -1690,6 +1710,87 @@ export function apply(ctx: any, config: PluginConfig): void {
});
});
// ─── 模型主动提问ask_user_question→ 转成邮件问人 ───
//
// 这是**第二个**人机交互 seam与上面的 approval/request 完全独立:
// - approval/request危险工具被 DSH 拦下,人批准/拒绝**执行**
// - ask_user_question模型自己需要确认/选择/补充信息,人**回答问题**
//
// 原来只桥接了前者。邮件驱动的会话没有本地 UI而 ask() 的 provider 是
// DSH host 注册的本地 UI 实现 —— 在那里等人点选,人永远看不到,
// promise 永不 resolve那一轮工具调用**永久挂死**。
//
// 为什么拦 `tools/execute` 而不抢注全局 provider
// `ctx.userQuestions.registerProvider` 只允许一个活动 provider第二个抛
// DUPLICATE_PROVIDER而 DSH host 已经注册了本地 UI 那个;抢注会让平台
// 自己的界面失效。around-dispatch 只对**邮件驱动**的会话接管,其余原样交给
// 下一个 wrapper / 平台 UI。
ctx.on('tools/execute', async (exec: any, next: () => Promise<any>) => {
if (exec?.name !== 'ask_user_question') return next();
const agentId = String(exec?.agent?.id ?? '');
if (!agentId || !mailDrivenSessions.has(agentId)) return next();
const mailSessionID = reverseMap.get(agentId);
if (!mailSessionID) return next();
const rawQuestions: any[] = Array.isArray(exec?.arguments?.questions)
? exec.arguments.questions
: [];
const relayKey = clampRelayKey(`${agentId}:ask:${exec?.callId ?? 'nocall'}`);
let flat: { question: string; options: string[]; context: string; multiSelect: boolean };
try {
flat = flattenQuestions(rawQuestions);
} catch (e: any) {
// 问题本身不合法(空列表)→ 当场把错误交回模型,不要挂死
throw new Error(`ask_user_question 参数不合法:${e?.message || e}`);
}
const mctx = mailContexts.get(mailSessionID);
try {
await client.post('/permission/request', {
question: flat.question,
options: flat.options,
context: [
flat.context,
mctx?.subject ? `触发任务:${mctx.subject}` : '',
mctx?.replyTo ? `任务来自:${mctx.replyTo}` : '',
].filter(Boolean).join('\n'),
session_id: mailSessionID,
relay_key: relayKey,
kind: 'question',
multi_select: flat.multiSelect,
});
} catch (e: any) {
// 与 approval 同一条纪律:不会因重试成功的失败必须**当场报错**
// 不能 `return next()` —— 下一个 answerer 是本地 UI而邮件驱动的会话
// 根本没有 UIwaterfall 跑到尾依旧无人应答 = 挂死。
const hint = [e?.body?.error, e?.body?.detail, e?.body?.suggestion]
.filter(Boolean).join(' ');
console.error(`[dsh-mail-bridge] 主动提问转发失败HTTP ${e?.status ?? '?'}${hint || e?.message || e}`);
throw new Error(
`无法把问题送达给人类HTTP ${e?.status ?? '?'}${hint ? `${hint}` : ''}`
+ `请改用不需要人工确认的方式继续,或在最终回复里说明需要人回答什么。`);
}
console.error(`[dsh-mail-bridge] 主动提问已转邮件 ${relayKey}kind=question`);
// 等人类回答DSH 撤销signal abort时以错误结算不装作答过了。
const answers = await new Promise<any>((resolve, reject) => {
pendingQuestions.set(relayKey, {
resolve, reject, questions: rawQuestions, sessionId: agentId,
});
exec.signal?.addEventListener('abort', () => {
if (pendingQuestions.delete(relayKey)) {
reject(new Error('ask_user_question 被取消(人类尚未回答)'));
}
}, { once: true });
});
// around-wrapper 返回的 canonical value 就是工具的输出(见 dsh-tools
// normalizeDispatchResult非错误结果取 .value 交给 output.schema 校验)。
return { isError: false, value: answers } as any;
});
// 把插件主动拒绝的真正原因递给模型。
//
// DSH 将 approval/request 的 'rejected' 翻译成写死的
@ -1724,9 +1825,32 @@ export function apply(ctx: any, config: PluginConfig): void {
};
});
/** 人类决策回来:先看是不是在等的那approval否则当普通通知投给会话。 */
/** 人类决策回来:先看是不是在等的那个问题/approval否则当普通通知投给会话。 */
function handlePermissionDecision(data: any): void {
const relayKey = String(data?.relay_key ?? '');
// 主动提问的回答与权限审批的结构不同answers[] vs ApprovalOutcome
// 必须分开结算。用 relay_key 查而不是信 data.kind键本身已经唯一。
const pendingQ = relayKey ? pendingQuestions.get(relayKey) : undefined;
if (pendingQ) {
pendingQuestions.delete(relayKey);
const decision = String(data?.decision ?? '');
const note = data?.note;
if (isBlankAnswer(pendingQ.questions, decision, note)) {
// 空回答不能当作「答了」:模型会拿到一个什么都没说的结果继续跑。
pendingQ.reject(new Error('人类提交了空回答;请重新提问或改用不需要回答的方式继续。'));
console.error(`[dsh-mail-bridge] 主动提问 ${relayKey} 收到空回答,已拒绝`);
return;
}
try {
pendingQ.resolve(answersFromDecision(pendingQ.questions, decision, note));
console.error(`[dsh-mail-bridge] 主动提问 ${relayKey} 已回答`);
} catch (e: any) {
pendingQ.reject(new Error(`回答解析失败:${e?.message || e}`));
}
return;
}
const pending = relayKey ? pendingApprovals.get(relayKey) : undefined;
if (pending) {
pendingApprovals.delete(relayKey);
@ -1802,6 +1926,11 @@ export function apply(ctx: any, config: PluginConfig): void {
pending.resolve('unavailable');
pendingApprovals.delete(key);
}
// 主动提问没有「unavailable」这种返回值只能以错误结算。
for (const [key, pending] of pendingQuestions) {
pending.reject(new Error('邮件桥已卸载,无法再接收回答'));
pendingQuestions.delete(key);
}
};
}, 'dsh-mail-bridge.sse');
}