跳到主要内容
路径文档

user-questions 服务缝与身份边界

一句话版ctx.userQuestions 是"暂停工具调用、向人提问"的能力缝。一个上下文里只有一个激活的 UI 提供者(重复注册报 DUPLICATE_PROVIDER,没有提供者 askNO_PROVIDER);ask() 在提问前做四类校验;带 agent只承认 AgentRegistry 里确切的 live 实例——用"运行时根归属"判身份,而不是 durable 会话血缘。插件/UI 开发者要接入,只需实现 { ask(request) }registerProvider

这篇是"交互与推进机制"的核心基础课。读完你清楚:模型怎么停下来等人回答、哪些错误码对应什么、为什么被另一个 live agent 拥有的子代理永远不该问人、以及你自己怎么写一个 UI 提供者。

一、能力缝的位置

@deepseek-ai/dsh-user-questions 是这个能力缝的 Service Definition(服务定义包)。它自己不渲染 UI,只拥有:

  • ctx.userQuestions —— UserQuestionService 服务
  • 一组 wire-safe 类型(AskUserQuestionRequest / AskUserQuestionAnswer 等)
  • 稳定错误码(UserQuestionError 子类)

消费方是模型面向的工具 @deepseek-ai/dsh-tool-ask-userask_user_question);UI 侧实现由宿主运行时提供,并作为唯一激活 provider 注册进来。主循环不变:工具调用 await 一个 promise,人的回答作为工具结果喂回 agent loop。

二、公开 API 与类型

// packages/interaction/user-questions/src/index.ts(摘)
export interface UserQuestionProvider {
ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>
}

// 请求 = 一组问题 + 可选的(确切 live)agent + abort signal
export interface AskUserQuestionRequest {
questions: AskUserQuestionItem[]
agent?: Agent
signal?: AbortSignal
}
API说明
registerProvider(provider)注册 UI 侧 provider;返回一个 disposer,调用后注销。一个上下文至多一个激活
ask(request)问当前激活 provider 并等待回答;带 agent 时先做身份校验

关键类型(user-questions/src/types.ts):

  • AskUserQuestionItem{ id, question, detail?, header?, options?, multiSelect?, intent? }detail 是随问题渲染、但不进选项标签的支持性文本
  • AskUserQuestionOption{ label, description? }(推荐的选项放第一位并加 "(Recommended)"
  • AskUserQuestionIntent{ kind: 'plan-review', approve },给能识别这个 tag 的 UI 一个"预定义演示意图"
  • AskUserQuestionAnswer{ answers: [{ id, selected, custom? }] }。单选时 custom 覆盖所选、selected 为空;多选时 custom 可补充 selected 的标签

单/多选与跳过

源码 README:单选问题 custom 覆盖所选、selected 为空;多选问题 custom 可补充 selected 里的标签。UI 可以用 { id, selected: [] } 保留一个跳过项,既保持答案形状又保留批次里其它答案。

演示意图(presentation intent)

intent 声明"这个问题某类已知决策",能识别的 UI 按该种类别呈现,否则渲染通用选项列表——只是呈现差异,协议不变,调用方读到的答案字段一模一样。approve 指名"哪个标签是批准",而不是靠选项顺序推断 verdict。dsh-plan-mode 会在 exit_plan_mode 问题上设 plan-review

三、ask() 的四类校验

ask()触达 provider 之前依次做这些校验,全部抛 UserQuestionError

顺序触发错误码
1signal 已 abortASK_ABORTED
2questions.length === 0EMPTY_QUESTIONS
3agent 但不是 registry 里确切 live 实例CALLER_NOT_LIVE
4是 live 实例但被另一个 live agent 拥有(非根)DELEGATED_CALLER
5任一问题的 intent 断言不成立(approve 标签不在本问题选项里,或 plan-review 无 detail)BAD_INTENT
6没有注册任何 providerNO_PROVIDER

源码 ask() 开头(user-questions/src/index.ts):

async ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
if (request.signal?.aborted) {
throw new UserQuestionError('ask_user_question was aborted before the user answered', 'ASK_ABORTED')
}
if (request.questions.length === 0) {
throw new UserQuestionError('ask_user_question requires at least one question', 'EMPTY_QUESTIONS')
}
const agent = request.agent
if (agent !== undefined) {
const agents = this.ctx.get('agents')
if (agents === undefined || agents.get(agent.id) !== agent) {
throw new UserQuestionError(
'human interaction requires the exact live calling agent when an agent is supplied',
'CALLER_NOT_LIVE')
}
if (!agents.roots().includes(agent)) {
throw new UserQuestionError(
'human interaction is unavailable while the calling agent is owned by another live agent; …',
'DELEGATED_CALLER')
}
}

}

四、身份边界:CALLER_NOT_LIVE / DELEGATED_CALLER

这是本篇最核心的部分。"谁能向人提问"由运行时根归属决定,而不是 durable 会话血缘

  • CALLER_NOT_LIVEagents.get(agent.id) !== agent —— 你不是 registry 里那个 id 登记的确切 live 实例。比如一个已替换/已注销的 Agent 句柄。源码注释:"exact live" 身份,比 agents.get(id) 还严格(还要 === 同一实例)
  • DELEGATED_CALLER:你是 registry 里的 live 实例,但 !agents.roots().includes(agent) —— 你被另一个 live agent 拥有。一个 owned 的子代理没有人类作答者,问下去会永久阻塞,所以被机械拒绝

关键:为什么是"运行时根归属"而不是血缘?源码 ask() 的 JSDoc 说得很清楚:

/**
* When a caller supplies an agent, human interaction is valid only for the
* exact live runtime root. Runtime ownership, not durable session lineage,
* decides this boundary: an owned child has no human answerer and would
* block forever, while a lineage-bearing session resumed as a new runtime
* root may ask normally.
*/
  • 一个带历史委托深度的会话,恢复成新的运行时根后可以正常问人(血缘深≠不可问)
  • 一个 live 的子代理,即使 delegationDepth 是 0,只要它还被别的 agent 拥有,就被拒绝

当子代理被拒绝时怎么办?错误信息给出指引:把没解决的疑问或决定放进子代理的最终结果,由父代理转交给人类。这是"子代理把未决问题回传给父"的约定。

五、BAD_INTENT:类型表达不了的两个断言

intent 声明了两件类型无法携带的事:

  1. approve 标签必须是本问题自己的选项之一 —— 否则 UI 会呈现一个提问者从没给过的选择
  2. 一个 plan-review 必须带 detail(它就是"被审查的计划")—— 否则 UI 会批准"看不见的东西"
// packages/interaction/user-questions/src/index.ts(摘)
for (const question of request.questions) {
const intent = question.intent
if (intent === undefined) continue
if (!(question.options ?? []).some(option => option.label === intent.approve)) {
throw new UserQuestionError(
`question ${question.id} declares intent ${intent.kind} whose approve label …`,
'BAD_INTENT')
}
if (question.detail === undefined) {
throw new UserQuestionError(
`question ${question.id} declares intent ${intent.kind} without the detail it reviews`,
'BAD_INTENT')
}
}

源码注释强调:在 asker(发起方)抓住错误,而不是让每个 UI 重复检查。

六、registerProvider:唯一激活提供者

// packages/interaction/user-questions/src/index.ts(摘)
registerProvider(provider: UserQuestionProvider): () => void {
const dispose = this.ctx.effect(function* (this: UserQuestionService) {
if (this.provider !== undefined) {
throw new UserQuestionError('a user-questions provider is already registered', 'DUPLICATE_PROVIDER')
}
this.provider = provider
yield () => { this.provider = undefined }
}.bind(this), 'userInteraction.registerProvider()')
return () => void dispose()
}
  • 一个上下文至多一个激活 provider:重复注册抛 DUPLICATE_PROVIDER
  • 用 Cordis ctx.effect 注册:返回的 disposer / 插件卸载时自动把 this.provider 清回 undefined,并注销
  • 没有提供者的场景:ask()NO_PROVIDER而不是降级——fail closed,而不是吞掉提问

源码 README 的 Known Limitations 也点出:只有"一个 provider per context",没有到多个 UI 的路由/扇出;交互词汇目前只有"问题表单形状"(可选 + 自定义文本),文件选择器、diff-preview 确认等更丰富形态尚无缝词汇。

七、给你:实现一个 UI 提供者

插件/UI 开发者接入这个缝只需三步:

import { ctx } from '@deepseek-ai/cordis' // 有 ctx.userQuestions
import type {
UserQuestionProvider,
AskUserQuestionRequest,
AskUserQuestionAnswer,
} from '@deepseek-ai/dsh-user-questions'

// 1) 实现 { ask(request) }:UI 呈现问题、收集答案、resolve
const uiProvider: UserQuestionProvider = {
async ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
const answers = await myRenderPipeline(request) // 你的 UI/CLI/邮箱…任意呈现
return { answers }
},
}

// 2) 注册成为唯一激活提供者(在 apply 里);得到 disposer
const dispose = ctx.userQuestions.registerProvider(uiProvider)

// 3) 卸载时注销
export function apply(host: typeof ctx): void {
const unregister = host.userQuestions.registerProvider(uiProvider)
host.on('dispose', unregister)
}

注意点:

  • 只注册一次:重复注册 = DUPLICATE_PROVIDER,所以接入前先确认没有别的 UI 已经注册,或约定好谁负责注册
  • 你的 ask() 可以 await 任意端点——主循环不关心你在哪呈现、怎么收答案,只要 resolve 成 AskUserQuestionAnswer
  • 自定义视觉/终端/Web UI、邮件、或者一个"无头"自动应答器,都只是一个 provider 的不同实现

八、模型面:tool-ask-user

@deepseek-ai/dsh-tool-ask-user 提供 ask_user_question 工具,把 seam 暴露给模型。它自己不实现 provider,只依赖 userQuestions 服务;校验/身份边界全部下沉到 ctx.userQuestions.ask()executeexec.agent 作为 agent 传入(有 agent 时才带),exec.signal 作为取消通道:

// packages/interaction/tool-ask-user/src/index.ts(摘)
async execute(args, exec) {
const result = await ctx.userQuestions.ask({
questions: args.questions.map(question => ({ id, question,})),
...exec.agent !== undefined ? { agent: exec.agent } : {},
signal: exec.signal,
})
return { answers: result.answers.map(a => ({ id: a.id, selected: [...a.selected],})) }
}

成功时模型得到紧凑 JSON 答案;失败时得到下列之一(README / index.ts 源码):

Error: ask_user_question was aborted before the user answered
Error: ask_user_question requires at least one question
Error: human interaction requires the exact live calling agent when an agent is supplied
Error: human interaction is unavailable while the calling agent is owned by another live
agent; include the unresolved question or decision in the child agent's final result
Error: no user-questions provider is registered
Error: <message>

九、验证

# 组合树里确认 tool-ask-user / user-questions 都装载
dsh web --dump-config | grep -iE "ask-user|user-questions" | head

# 会话里看 tool-ask-user 的调用与结果
zstdcat ~/.dsh/sessions/*/*/session.jsonl.zstd | grep -E '"tool/call"|ask_user_question' | head

# 在一个"被拥有的子代理"里调 ask_user_question,观察 DELEGATED_CALLER
zstdcat ~/.dsh/sessions/*/*/session.jsonl.zstd | grep -E 'DELEGATED_CALLER|CALLER_NOT_LIVE' | head

想在 UI 里实际看到问题,启动 Web UI 后在会话里让模型调用 ask_user_question(例如"我需要你确认再继续"),观察 host 提供的 provider 弹出问题并回填答案。

下一步

  • 工具执行:工具调用怎么 await 并在结果后恢复主循环
  • 写服务:如何用 ctx.provide 提供自己的 seam
  • 写一个工具tool-ask-user 这类消费方怎么建