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):

  1. signal 已中止 → ASK_ABORTED
  2. questions 为空 → EMPTY_QUESTIONS
  3. 传入 agent 时:必须是注册表中精确的 live 实例(否则 CALLER_NOT_LIVE);且必须是运行时根(被其他 agent 拥有则 DELEGATED_CALLER,见 §6)
  4. 声明了 intent 的问题:approve label 必须是 options 之一(BAD_INTENT),且必须携带 detail(BAD_INTENT)
  5. 无已注册 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[]

字段类型必填说明
idstring✅稳定 id,答案中回传
questionstring✅问题正文
headerstring—短标题,如「确认」「选择模式」
options[]array—选项列表;缺省则该题为纯填空题
options[].labelstring✅用户可见选项文本(答案回传的就是 label);推荐项追加 (Recommended) 或 (推荐),UI 会显示「推荐」徽标
options[].descriptionstring—选项的一句话说明(渲染在选项下方)
multi_selectboolean—是否多选,默认 false(单选)

4.2 输出 answers[]

字段类型必填说明
idstring✅对应问题 id
selectedstring[]✅选中的选项 label 列表
customstring—自定义文本答案

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_QUESTIONSquestions 数组为空工具报错
NO_PROVIDER无 UI Provider 注册(如非 Web 环境)工具报错
DUPLICATE_PROVIDERProvider 重复注册服务层报错
CALLER_NOT_LIVE传入的 agent 不是注册表里的精确 live 实例工具报错
DELEGATED_CALLER子代理(subagent)试图询问用户工具报错;子代理必须把未决问题/决策带回最终结果,由父代理(根)来问
BAD_INTENTintent 的 approve label 不在选项中,或 plan-review 缺 detail工具报错(在 asker 侧拦截,避免把用户没见过的选项或不可见计划摆到用户面前)

关键设计约束

  1. 只有运行时根代理能问用户:子代理没有人类应答者,阻塞将永远挂起,因此被硬性拒绝。
  2. 提问可被中止:signal 在问答等待期间被 abort 时立即抛 ASK_ABORTED。
  3. 答案回传的是 label 原文:模型应把用户看到的文本与收到的 selected 一致对待。
  4. 跳过 ≠ 拒绝:跳过返回空答案,模型需要决定如何继续(例如标记为「用户未指定」并采用默认)。

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 步)与 Client QuestionComposer 路由两处同步扩展。
  • 会话时间线呈现:AskQuestionRow 是 tool.call.toolview key = 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(时间线工具行)