跳到主要内容
路径文档

user-questions 服务缝与身份边界

审计基线 0.1.5-alpha.1 @ 5dda764ed3;见 源码 / npm 渠道。

一句话版:ctx.userQuestions 是"暂停工具调用、向人提问"的能力缝。ask() 先做五道校验,再派发 Agent 作用域的 Cordis waterfall user-questions/request:谁先返回答案谁认领,全部 next() 则报 NO_PROVIDER(fail closed)。带 agent 时只承认 AgentRegistry 里确切的 live 实例——用"运行时根归属"判身份,而不是 durable 会话血缘。插件/UI 开发者要接入,只需在 user-questions/request 上注册一个 answerer。

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

一、能力缝的位置​

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

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

消费方是模型面向的工具 @deepseek-ai/dsh-tool-ask-user(ask_user_question);UI 侧实现是组合里挂在 user-questions/request waterfall 上的 answerer(官方 Web 的 answerer 在浏览器半部,经 Remote Events 转发注册)。主循环不变:工具调用 await 一个 promise,人的回答作为工具结果喂回 agent loop。

二、公开 API 与类型​

// packages/interaction/user-questions/src/types.ts(摘)
export interface AskUserQuestionRequestEvent {
questions: AskUserQuestionItem[]
agent?: Agent
signal?: AbortSignal
}

// packages/interaction/user-questions/src/index.ts(摘)
export interface AskUserQuestionRequest extends AskUserQuestionRequestEvent {}

// Cordis waterfall 声明:返回答案即认领,调用 next() 交给下一个 answerer
'user-questions/request'(
this: Scoped<Agent>,
request: AskUserQuestionRequestEvent,
next: () => Promise<AskUserQuestionAnswer>,
): Promise<AskUserQuestionAnswer>
API说明
ask(request)校验后派发 user-questions/request waterfall 并等待第一个被接受的答案;带 agent 时先做身份校验
ctx.on('user-questions/request', (request, next) => …)answerer 的接入方式:返回答案即认领该请求,return next() 交给下一个 answerer

关键类型(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() 在派发 waterfall 之前依次做这些校验,全部抛 UserQuestionError:

顺序触发错误码
1signal 已 abortASK_ABORTED
2questions.length === 0EMPTY_QUESTIONS
3带 agent 但不是 registry 里确切 live 实例CALLER_NOT_LIVE
4是 live 实例但被另一个 live agent 拥有(非根)DELEGATED_CALLER
5任一问题的 intent 断言不成立(approve 标签不在本问题选项里,或 plan-review 无 detail)BAD_INTENT
6waterfall 上没有任何 answerer 认领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')
}
}
// … BAD_INTENT 校验(见第五节)之后:
const noAnswerer = () => Promise.reject(new UserQuestionError(
'no user-questions answerer accepted the request',
'NO_PROVIDER',
))
return await (agent === undefined
? this.ctx.waterfall('user-questions/request', request, noAnswerer)
: this.ctx.waterfall(
scopeTarget(agent, agent),
'user-questions/request',
{ ...request, agent },
noAnswerer,
))
}

四、身份边界: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 声明了两件类型无法携带的事:

  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 重复检查。

六、answerer 的接入方式:Agent 作用域 waterfall​

服务本身没有 provider 注册表。ask() 派发的是 Cordis waterfall user-questions/request(packages/interaction/user-questions/src/types.ts 的 Events 声明):

declare module '@deepseek-ai/cordis' {
interface Events {
/**
* Ask composed answerers for structured user input. Return an answer to
* claim the request or call `next()` to delegate. Scope-filtered dispatch
* (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode waterfall
*/
'user-questions/request'(
this: Scoped<Agent>,
request: AskUserQuestionRequestEvent,
next: () => Promise<AskUserQuestionAnswer>,
): Promise<AskUserQuestionAnswer>
}
}
  • 返回答案即认领:第一个返回 AskUserQuestionAnswer 的 listener 结束 waterfall;return next() 表示"我不处理,交给下一个"
  • 作用域过滤:带 agent 的请求经 scopeTarget(agent, agent) 派发,只有该 agent 作用域的 listener 收到;不带 agent 的请求不会到达 Web answerer(它只处理 Agent 作用域请求),需要无作用域的本地 listener 接手
  • 没有 listener 认领:兜底的 noAnswerer 抛 NO_PROVIDER,而不是降级——fail closed,而不是吞掉提问
  • listener 是普通的 ctx.on(...) 注册,随插件 fiber 卸载自动注销;同一个 waterfall 可以叠多个 answerer,不存在"重复注册"错误

官方 Web 的 answerer 就是浏览器半部的一条 ctx.remote.$on('user-questions/request', (request, next) => …):Host 侧 packages/api/remotes/src/remote-events.ts 以 { event: 'user-questions/request', mode: 'waterfall' } 把它转发到浏览器,浏览器返回答案后再回传 Host(源码 packages/client/ui-user-questions/src/client/index.ts)。

源码 README 的 Known Limitations 也点出:交互词汇目前只有"问题表单形状"(可选 + 自定义文本),文件选择器、diff-preview 确认等更丰富形态尚无缝词汇。

七、给你:实现一个 answerer​

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

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

export function apply(ctx: Context): void {
// 返回答案 = 认领该请求;return next() = 交给下一个 answerer
ctx.on('user-questions/request', async (request, next) => {
if (!canHandle(request)) return next()
return renderAndCollect(request) // 你的 UI/CLI/邮箱…任意呈现,resolve 成 AskUserQuestionAnswer
})
}

注意点:

  • 认领是排他的:waterfall 遇到第一个返回值就结束,所以不确定要不要处理时必须 next(),不能返回 undefined 之外的假值
  • 你的 answerer 可以 await 任意端点——主循环不关心你在哪呈现、怎么收答案,只要 resolve 成 AskUserQuestionAnswer
  • 自定义视觉/终端/Web UI、邮件、或者一个"无头"自动应答器,都只是同一个 waterfall 上不同的 answerer
  • 要接住不带 agent 的编程式请求,必须注册无作用域 listener;官方 Web answerer 只接 Agent 作用域请求

八、模型面:tool-ask-user​

@deepseek-ai/dsh-tool-ask-user 提供 ask_user_question 工具,把 seam 暴露给模型。它自己不挂 answerer,只依赖 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.id,
question: question.question,
...question.header !== undefined ? { header: question.header } : {},
...question.options !== undefined ? { options: question.options } : {},
...question.multi_select !== undefined ? { multiSelect: question.multi_select } : {},
})),
...exec.agent !== undefined ? { agent: exec.agent } : {},
signal: exec.signal,
})
return {
answers: result.answers.map(answer => ({
id: answer.id,
selected: [...answer.selected],
...answer.custom !== undefined ? { custom: answer.custom } : {},
})),
}
}

成功时模型得到紧凑 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 answerer accepted the request
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(例如"我需要你确认再继续"),观察浏览器侧 answerer 弹出问题并回填答案。

下一步​

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