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:
@ -62,6 +62,11 @@ import { appendRenameProposal, renameProposalNote } from '../lib/rename-proposal
|
||||
import { createSSEClient } from '../lib/sse-client.js';
|
||||
// 只用 isApproval:DSH 没有 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,而邮件驱动的会话
|
||||
// 根本没有 UI,waterfall 跑到尾依旧无人应答 = 挂死。
|
||||
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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user