DSH 用户确认机制(askuserquestion)完整触发链路文档
August 15, 2026 · View on GitHub
本文档基于对当前运行实例源码的实测整理,描述 DSH 中「模型在任务执行过程中需要向用户确认信息」这一能力的完整机制:从模型触发、宿主服务桥接、Web UI 渲染,到答案回传的整条链路。
1. 一句话概述
模型在工作过程中调用内置工具 ask_user_question,该工具会挂起当前 agent 循环,由 Web 前端渲染一张结构化问答卡片(支持单选题、多选题、填空题、多题分页、跳过、取消,以及独立的「计划审批」卡片形态);用户作答后,答案以结构化 JSON 工具结果的形式返回给模型,模型继续执行。不需要用户手写答案,也不需要用户理解任何格式。
模型 ──调用──▶ ask_user_question ──▶ ctx.userQuestions.ask()
│ 挂起 agent 循环
▼
Web UI 渲染问答卡片 ◀──用户作答
│
▼
工具结果 { answers: [...] } 回到模型
▼
模型继续执行
2. 涉及的组件与包
| 层 | 包 | 职责 |
|---|---|---|
| Host | @deepseek-ai/dsh-tool-ask-user | 注册模型可见工具 ask_user_question,定义参数/输出 schema,调用 userQuestions 服务 |
| Host | @deepseek-ai/dsh-user-questions | 能力缝(seam):ctx.userQuestions 服务,ask() API + 单 Provider 注册,校验与错误码 |
| Client | @deepseek-ai/dsh-client-ui-user-questions | 问答卡片 UI:QuestionComposer / QuestionFlow(通用)/ PlanReviewPanel(计划审批) |
| Client | @deepseek-ai/dsh-client-ui-tool | 会话时间线里 ask_user_question 的工具行(AskQuestionRow,显示等待/已答/取消/中断状态) |
⚠️ 注意:
dsh-client-ui-user-questions的 Host 半是故意为空的——「能问问题」是 agent 能力,由各 agent preset 自行决定是否挂载tool-ask-user行(以及 TUI 组合)。userQuestions服务提供的是渲染能力,二者解耦。
3. 完整触发时序
3.1 模型侧触发
模型在对话中判断需要确认/选择/补充信息时,调用工具:
// 工具参数(模型侧)
{
"questions": [
{
"id": "mode", // 必填:稳定 id,答案中原样回传
"question": "希望我以哪种方式继续?", // 必填:问题正文
"header": "选择模式", // 可选:小标题(眉标)
"options": [ // 可选:选项;不提供则为纯填空题
{ "label": "快速 (Recommended)", "description": "优先速度,跳过详尽检查" },
{ "label": "保守" }
],
"multi_select": false // 可选:是否多选,默认 false
},
{
"id": "path",
"question": "目标目录是?"
}
]
}
3.2 Host 服务桥接(ctx.userQuestions.ask)
工具 execute 将参数映射后调用 ctx.userQuestions.ask():
await ctx.userQuestions.ask({
questions: [...], // id / question / header / options / multiSelect
agent: exec.agent, // 调用方 agent(用于归属校验)
signal: exec.signal, // 中止信号
})
服务端校验顺序(任一步失败即抛 UserQuestionError):
signal已中止 →ASK_ABORTEDquestions为空 →EMPTY_QUESTIONS- 传入 agent 时:必须是注册表中精确的 live 实例(否则
CALLER_NOT_LIVE);且必须是运行时根(被其他 agent 拥有则DELEGATED_CALLER,见 §6) - 声明了
intent的问题:approve label 必须是 options 之一(BAD_INTENT),且必须携带detail(BAD_INTENT) - 无已注册 UI Provider →
NO_PROVIDER
校验通过后委托给 UI Provider,ask() 的 Promise 挂起,直到用户作答。
3.3 Client 渲染(composer 接管)
客户端插件注册到 conversation.composer slot 链(输入框接管链),选择器:
function selectQuestion({ interactions }) {
return interactions.find((i) => i.kind === "question") ?? null;
}
当存在挂起的 question 交互时,卡片替换输入框区域渲染(QuestionComposer),并在入口按请求内容路由:
planReviewOf(questions) 命中(计划审批)──▶ PlanReviewPanel
否则 ──▶ QuestionFlow(通用问答)
3.4 用户作答与回传
用户交互后,PendingQuestion 通过 wait.respond() 把答案编码回 Host:
// 作答
await wait.respond({
ok: true,
value: { sessionId, answer: { answers: [...] } },
})
// 取消整组(点 ✕ 或「去聊天里说」)
await wait.respond({
ok: false,
error: { code: "cancelled", message: "the user closed this question request", details: {} },
})
Host 解析后,工具 execute 返回结构化结果,模型拿到继续执行:
// 工具输出(模型收到的结果)
{
"answers": [
{ "id": "mode", "selected": ["快速"], "custom": "" }, // 选了选项
{ "id": "path", "selected": [], "custom": "/Users/me/foo" } // 填了自由文本
]
}
| 字段 | 含义 |
|---|---|
answers[].id | 对应问题 id,原样回传 |
answers[].selected | 用户点选的选项 label 数组(单选一个元素;多选多个;未答为空数组) |
answers[].custom | 用户手填的自由文本;未填则缺省 |
跳过的题目返回
{ id, selected: [] }(无 custom)。模型需自行处理「未回答」的情况。
4. 工具契约(完整 Schema)
4.1 输入参数 questions[]
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
id | string | ✅ | 稳定 id,答案中回传 |
question | string | ✅ | 问题正文 |
header | string | — | 短标题,如「确认」「选择模式」 |
options[] | array | — | 选项列表;缺省则该题为纯填空题 |
options[].label | string | ✅ | 用户可见选项文本(答案回传的就是 label);推荐项追加 (Recommended) 或 (推荐),UI 会显示「推荐」徽标 |
options[].description | string | — | 选项的一句话说明(渲染在选项下方) |
multi_select | boolean | — | 是否多选,默认 false(单选) |
4.2 输出 answers[]
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
id | string | ✅ | 对应问题 id |
selected | string[] | ✅ | 选中的选项 label 列表 |
custom | string | — | 自定义文本答案 |
5. UI 形态详解
5.1 通用问答卡片(QuestionFlow)
卡片渲染在输入框位置(替换 composer),元素包括:
- 头部:可选眉标(
header)+ 问题标题(question),右上角 ✕(放弃整组问题) - 详情区:可选
detail字段,以 Markdown 渲染 - 选项区:
- 单选(默认):编号圆形按钮(
radiogroup),点击即选中并自动跳到下一题 - 多选(
multi_select: true):checkbox 组,可勾选多个,不自动跳题 - 选项可带描述;
(Recommended)/(推荐)后缀会被剥离显示并加「推荐」徽标
- 单选(默认):编号圆形按钮(
- 自定义答案行(有选项时):单选模式下是「输入你的答案」单行输入框(输入后清除已选选项,单选互斥);多选模式下可勾选后另行输入
- 纯填空题(无选项):直接渲染一个自动聚焦的
textarea(2 行) - 底部:上一题/下一题分页(显示「1 / N」进度)、「跳过本题」、主操作按钮(「下一题」/「提交」);提交前校验未完成项并提示
- 校验反馈:未作答提交 → 提示「请选择一个选项或填写自定义答案。」;多题漏答 → 提示「请先完成这道问题。」并跳回
- Enter 快捷键:填空题回车(非 IME 组合中、非 Shift)直接进入下一题/提交;选项聚焦时 Enter 提交全部已答
5.2 计划审批卡片(PlanReviewPanel)
当问题声明了 intent: { kind: "plan-review", approve: <label> } 且满足窄化条件时,渲染独立审批卡:
- 顶部警示条「计划待审」
- 主体:计划全文(Markdown 滚动区)
- 底部三个按钮:
- 确认执行(primary,label = intent.approve)
- 拒绝(outline,即唯一非 approve 选项;最多 2 个选项、非多选才窄化)
- 去聊天里说(ghost)→ 取消本次提问,用户可回聊天框自由表达
窄化条件(
planReviewOf):整批只有 1 题、intent.kind === "plan-review"、带detail、非多选、选项数 ≤ 2、approve label 确实是其中一个选项。不满足则回退通用流程。
5.3 会话时间线中的工具行(AskQuestionRow)
工具调用在聊天流中呈现为一行卡片(tool.call.toolview key = ask_user_question):
- 运行中:显示「等待回答」状态
- 完成:摘要「已回答 X / N」(X = 有
selected或非空custom的题数) - 用户取消:显示「已取消」
- 运行中止:显示「已中断」并标为 stopped
- 可展开查看输入/输出
6. 边界情况与错误码
| 错误码 | 触发场景 | 模型侧表现 |
|---|---|---|
ASK_ABORTED | 提问挂起期间运行被中止 | 工具以 error 结束,摘要「已中断」 |
ASK_CANCELLED / cancelled | 用户关闭卡片(✕ 或「去聊天里说」) | 工具以 error 结束,摘要「已取消」;模型应继续推进(如解释、重问、改方案) |
EMPTY_QUESTIONS | questions 数组为空 | 工具报错 |
NO_PROVIDER | 无 UI Provider 注册(如非 Web 环境) | 工具报错 |
DUPLICATE_PROVIDER | Provider 重复注册 | 服务层报错 |
CALLER_NOT_LIVE | 传入的 agent 不是注册表里的精确 live 实例 | 工具报错 |
DELEGATED_CALLER | 子代理(subagent)试图询问用户 | 工具报错;子代理必须把未决问题/决策带回最终结果,由父代理(根)来问 |
BAD_INTENT | intent 的 approve label 不在选项中,或 plan-review 缺 detail | 工具报错(在 asker 侧拦截,避免把用户没见过的选项或不可见计划摆到用户面前) |
关键设计约束
- 只有运行时根代理能问用户:子代理没有人类应答者,阻塞将永远挂起,因此被硬性拒绝。
- 提问可被中止:
signal在问答等待期间被 abort 时立即抛ASK_ABORTED。 - 答案回传的是 label 原文:模型应把用户看到的文本与收到的
selected一致对待。 - 跳过 ≠ 拒绝:跳过返回空答案,模型需要决定如何继续(例如标记为「用户未指定」并采用默认)。
7. 与审批(Approval)机制的关系
| 维度 | 审批机制 | 用户确认机制 |
|---|---|---|
| 触发方 | 工具调用前/中,由审批策略(ask / never 等)自动拦截 | 模型显式调用 ask_user_question |
| 目的 | 授权危险操作 | 获取信息/选择/确认 |
| UI | 审批弹窗 | composer 问答卡片 / 计划审批卡 |
| 用户改动「never」的影响 | 危险操作不再询问、直接执行 | 无影响,问答卡片照常工作 |
两者完全独立:把审批策略改成
never不会禁用ask_user_question;反过来,模型主动提问也不受审批策略约束。
8. 扩展与自定义要点(面向插件开发)
- 给某个 preset 加提问能力:在对应 agent preset 的组合中挂载
tool-ask-user行(而不是改dsh-client-ui-user-questions的 host 半,它故意为空)。 - 换一套问答 UI:实现自己的 Provider 并通过
ctx.userQuestions.registerProvider(provider)注册(全局同一时间仅一个 active provider,重复注册抛DUPLICATE_PROVIDER)。 - 自定义问题形态:
intent目前只有plan-review一种内置形态;新增形态需在 Host 服务校验(§3.2 第 4 步)与 ClientQuestionComposer路由两处同步扩展。 - 会话时间线呈现:
AskQuestionRow是tool.call.toolviewkey =ask_user_question的占用者,替换它即可自定义该工具在聊天流里的呈现。
9. 参考实现位置
<dsh>/node_modules/@deepseek-ai/
├── dsh-tool-ask-user/lib/index.js # 工具定义与 execute
├── dsh-user-questions/lib/index.js # UserQuestionService(registerProvider / ask)
├── dsh-user-questions/lib/types/index.js # 契约类型与 UserQuestionError 定义
├── dsh-client-ui-user-questions/lib/client.js # QuestionComposer/QuestionFlow/PlanReviewPanel + locale(zh/en)
└── dsh-client-ui-tool/lib/client.js # AskQuestionRow(时间线工具行)