user-questions 服务缝与身份边界
一句话版:
ctx.userQuestions是"暂停工具调用、向人提问"的能力缝。一个上下文里只有一个激活的 UI 提供者(重复注册报DUPLICATE_PROVIDER,没有提供者ask报NO_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-user(ask_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:
| 顺序 | 触发 | 错误码 |
|---|---|---|
| 1 | signal 已 abort | ASK_ABORTED |
| 2 | questions.length === 0 | EMPTY_QUESTIONS |
| 3 | 带 agent 但不是 registry 里确切 live 实例 | CALLER_NOT_LIVE |
| 4 | 是 live 实例但被另一个 live agent 拥有(非根) | DELEGATED_CALLER |
| 5 | 任一问题的 intent 断言不成立(approve 标签不在本问题选项里,或 plan-review 无 detail) | BAD_INTENT |
| 6 | 没有注册任何 provider | NO_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_LIVE:agents.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 声明了两件类型无法携带的事:
approve标签必须是本问题自己的选项之一 —— 否则 UI 会呈现一个提问者从没给过的选择- 一个
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()。execute 里 exec.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 弹出问题并回填答案。