English | 中文
dsh-user-questions 的用户交互 seam。它是工具或权限插件需要人类回答后 agent(智能体)才能继续时所使用的、提供方无关的词汇。Agent-scoped waterfall listener 组合可用的 UI 界面,其中包括转发到已连接 client 的 listener。
源码:packages/interaction/user-questions/src/index.ts
问题选项
AskUserQuestionOption 包含一个可供选择的选项。label 是面向用户的选项文字,同时也是面向模型的选中值;description 是可选的 UI 帮助文本。
/** One selectable answer offered to the user. */
interface AskUserQuestionOption {
/** User-facing label. */
label: string
/** Optional extra context rendered by capable UIs. */
description?: string
}
呈现意图
AskUserQuestionIntent 可选地声明一种已知的决策类型。它以 kind 作为标记,以便后续加入更多意图;不认识某个标记的 UI 会渲染通用选项列表。意图只改变呈现——遵循它的 UI 回答的选项标签与通用 UI 相同,因此调用方读到的答案字段一致。approve 指明表示同意的选项,而不依赖选项顺序。ask() 拒绝两种类型无法表达的断言:approve 未命名该问题自己的任何选项,以及没有 detail 的问题声明了意图。
/**
* A caller-declared presentation intent: the question IS this kind of
* decision, so a UI that recognises the tag may present it as such instead of as a
* generic option list. Tagged so further intents can be added; a UI that does
* not know a tag renders the generic flow, and the answer encoding is identical
* either way — an intent changes presentation only, never the protocol.
*/
type AskUserQuestionIntent = {
/** A plan submitted for review: `detail` is the plan markdown `ask()` requires, and the decision approves or declines it. */
kind: 'plan-review'
/**
* The option label that approves the plan; every other option declines it.
* Named rather than positional so no UI infers the verdict from option order.
* An `approve` naming no option of its own question is rejected at `ask()`.
*/
approve: string
/** Logged tool invocation whose arguments contain the reviewed plan. */
callId?: ToolCallId
}
问题条目
AskUserQuestionItem 是请求中的一个问题。调用方提供稳定的 id,它会随答案原样返回,使批量问题仍可路由。可选的 detail 携带辅助文本;提供方会将其随问题渲染,但不会放入可选选项标签。
/** One question in a user-questions request. */
interface AskUserQuestionItem {
/** Stable caller-provided question id, echoed in the answer. */
id: string
/** The question to display. */
question: string
/** Optional supporting detail rendered with the question but kept out of option labels. */
detail?: string
/** Optional short heading/group label. */
header?: string
/** Optional choices the UI can render as a menu. */
options?: AskUserQuestionOption[]
/** Whether more than one option may be selected. Defaults to single-select. */
multiSelect?: boolean
/** Optional presentation intent for capable UIs; absent asks for the generic option list. */
intent?: AskUserQuestionIntent
}
提问请求
AskUserQuestionRequest 是跨包请求。questions 是数组,这样 UI 可以在一个流程中呈现相关提示,同时保持每个回答有稳定的 id。如提供 agent,它必须与存活调用方是同一实例;只有当当前注册表将该实例识别为运行时根时,交互 seam 才会接纳该 agent。
/** Request for a human answer. */
interface AskUserQuestionRequest extends AskUserQuestionRequestEvent {}
回答
提供方为每个问题 id 返回一个回答项。selected 包含选中的选项标签,custom 在用户输入自由文本时携带「其他」回答。对于单选题,custom 会覆盖选中的选项,且 selected 为空。对于多选题,custom 可以补充 selected 中的标签。UI 也可以使用 selected 为空且不含 custom 的回答项,在其余问题均已完成的批次中保留被跳过的问题。
/** Answer to one question. */
interface AskUserQuestionAnswerItem {
/** The answered question id. */
id: string
/** Selected option labels. May accompany custom text for a multi-select question. */
selected: string[]
/** Optional free-text "Other" answer. */
custom?: string
}
/** The human's answer. */
interface AskUserQuestionAnswer {
/** Structured answers keyed by question id. */
answers: AskUserQuestionAnswerItem[]
}
计时回答器收到 wait: { callId, timed: true }。调用 id 标识卡片及前台等待。Client 调用 attachWait 接手等待并取得剩余时长;传输层转发原事件,不检查或改写这些字段。不带 timed 的 wait 表示无限期等待的具名卡片请求;没有 wait 则表示不带卡片 key 的阻塞请求。
/** Client-safe payload declared for the user-question answerer waterfall. */
interface AskUserQuestionRequestEvent {
/** Questions to display. */
questions: AskUserQuestionItem[]
/** Agent identity projected to the corresponding Client Context in transit. */
agent?: Agent
/** Cancellation lifetime of the pending request. */
signal?: AbortSignal
/**
* Tool call the Client card is keyed by. Timed answerers attach to the
* business wait stream before starting their local countdown.
*/
wait?: {
/** Tool call the Client card is keyed by. */
callId: ToolCallId
/** True for a foreground wait that requires a Client claim; absent for indefinite waits. */
timed?: boolean
}
}
计时问题与迟到回复
计时问题在有界的前台窗口内等待,然后让 agent 继续独立工作。用户在窗口内回答,工具返回整批答案;否则 TimedUserQuestionResult 为 { pending: true, callId }。pending 表示问题仍可回答,不是空答案、拒绝、确认或授权。
/** Timed ask result returned when the foreground answer window closes. */
type TimedUserQuestionResult = AskUserQuestionAnswer | { pending: true; callId: ToolCallId }
Client 接手通过业务 Remote stream 表达,不等同于 Gateway 投递。TimedQuestionWait 保存原 Host deadline,仅在没有接手记录时运行计时器。attachWait stream 发出当前剩余时长,Client 用本地时钟倒计时。聚焦、编辑和「慢慢回答」状态仍归本地所有。stream 取消释放相应接手记录;最后一个接手方离开后,Host 恢复原 deadline。无人接手时超时中止前台 waterfall 专属的 signal 并返回 pending,不取消 Turn。Gateway 与 api-remotes 不包含问答专属计时策略。
userQuestions Session 投影把现有事件折叠成可回答的问题集合与已结算的集合;不记录任何等待状态、聚焦状态或 deadline。每个 request/header 都记录了模型看到的确切工具 schema。只有请求头声明了带 timeout 参数的 timed ask_user_question schema,投影才跟踪原生调用;阻塞式 legacy 工具从不声明它。原生 tool/call 打开一个问题。tool/result 只在两种情况下把它标为 continued:带 pending 载荷,或是 Session resume 修复补写的 TOOL_OUTCOME_UNKNOWN 合成结果;回答批次把它连同该批次移入 settled,其他结果或失败都丢弃它。run_code 的 PTC 子调用若在 tool/ptc-dispatch 结果中返回 pending,也会进入投影,尽管请求头只列出 run_code。迟到回复在消息获准成为 user/message 时,才把已继续的问题连同消息中的回答批次移入 settled:计时调用自己的结果记录的是超时,transcript 行无处可读用户最终的选择。排队的回复可在准入前取消,此时问题仍可回答。回复正文不含可读批次时,该调用以零答案结算;回复指向的调用已不在可回答集合中时不改变任何内容。
/** `open` while the tool call may still return the answer; `continued` once the answer can only arrive as a new turn. */
type UserQuestionState = 'open' | 'continued'
/** One unanswered timed `ask_user_question` call reconstructed from the Session log. */
interface PendingUserQuestion {
readonly callId: ToolCallId
readonly questions: readonly AskUserQuestionItem[]
readonly state: UserQuestionState
}
/**
* One timed `ask_user_question` call and the answers it settled with: the
* batch its own result carried when the user answered inside the window,
* otherwise the batch its late reply carried, because that call's own result
* recorded the timeout. A transcript row reads what the user finally answered
* from here, and only a call listed here was a timed one.
*/
interface SettledUserQuestion {
/** The settled call. */
readonly callId: ToolCallId
/** The recorded batch, one entry per question; empty when the late reply carried none. */
readonly answers: readonly AskUserQuestionAnswerItem[]
}
/**
* Both halves of one Session's timed `ask_user_question` state, as every
* Client reads them. A call made while the blocking legacy tool was in
* effect appears in neither half.
*/
interface UserQuestionProjectionView {
/** Calls that can still take an answer, in ask order. */
readonly active: readonly PendingUserQuestion[]
/** Calls an answer settled, in settlement order. */
readonly settled: readonly SettledUserQuestion[]
}
开放中的问题只能通过 waterfall 回答。问题变为 continued 后,answer Remote 方法向所属 agent 排入一条用户消息,并在其空闲时唤醒它。消息的 source 为 user-question-reply,outcome 为 answered,正文是 answer_to_pending_question 载荷。开放或未知调用返回 false;回复仍在排队时的第二次提交以 REPLY_QUEUED 拒绝,Host 重启后也一样;遗漏或重复问题的回答批次以 BAD_ANSWER 拒绝。
收起 Client 面板不会发送内容。前台超时只结束工具等待。已继续的问题没有期限;在回复进入会话前,问题仍可回答。
Host 重启后,Session 重开时 Remote 层会恢复根 agent。之后的迟到回复会作为新一轮进入。
错误
UserQuestionError 继承 HarnessError,因此 ctx.tools.execute() 会保留 { name, code },用于面向模型的工具失败,如 EMPTY_QUESTIONS、BAD_INTENT、NO_PROVIDER、ASK_ABORTED 或 UI 侧取消。
/** Stable error taxonomy for user-questions failures. */
class UserQuestionError extends HarnessError {
constructor(message: string, code: string, options?: ErrorOptions) {
super(message, code, options)
this.name = 'UserQuestionError'
}
}
Cordis API
Generated from source by scripts/gen-cordis-catalog.ts (verified fresh by pnpm run verify-cordis-catalog in doc-sync; regenerate with pnpm run gen-cordis-catalog) — the language sides differ only in locale-specific paired document paths. Signature blocks use a ts cordis-catalog fence and keep the original source JSDoc; dispatch modes are defined in the primer, and the framework-inherited ctx API lives in cordis-api/inherited.md.
ctx.userQuestions — UserQuestionService
ctx.userQuestions: validation plus the scoped answerer waterfall.
/**
* Answer a continued question. The reply is steered into the agent as a
* user message whose source names the call; that message is also the
* record that closes the question in the projection.
* @param agent - Live root agent for the owning Session.
* @param callId - Continued question identity.
* @param answer - Complete structured answer batch, one item per question of the call.
* @returns Whether the question is still continued; an accepted reply stays
* queued until the agent admits its user message.
* @throws {UserQuestionError} `BAD_ANSWER` when the batch does not name each
* question of the call exactly once, or `REPLY_QUEUED` when a reply is
* already waiting for admission.
*/
@Remote answer(agent: Agent, callId: ToolCallId, answer: AskUserQuestionAnswer): boolean
/**
* Let one answer UI hold a live timed wait. Closing the stream releases its claim.
* @param agent - Live root agent owning the question.
* @param callId - Foreground tool call to attach to.
* @param signal - Remote stream cancellation, including Client disconnect.
* @returns One Host-computed remaining duration, or no frames once the wait ended.
*/
@Remote({ mode: 'stream' }) async *attachWait(agent: Agent, callId: ToolCallId, signal: AbortSignal): AsyncIterable<{ remainingMs: number }>
/**
* Foreground wait whose first settlement the Client decides: the Client
* rejects with `ASK_TIMED_OUT` when its countdown ends, and this method maps
* that code to the pending result.
* @param request - Questions, live owner agent, and abort signal.
* @param callId - Tool call identity the Client card is keyed by.
* @param timeoutMs - Positive foreground wait in milliseconds.
* @returns The answer when it arrives inside the window, otherwise a pending
* result, also when no connected Client claimed the request by the deadline.
* @throws {UserQuestionError} `BAD_TIMEOUT` for a non-integer, non-positive,
* or oversized wait.
*/
async askTimed( request: AskUserQuestionRequest & { agent: Agent }, callId: ToolCallId, timeoutMs: number, ): Promise<TimedUserQuestionResult>
/**
* Ask the scoped answerer waterfall and wait for the user's answer.
*
* 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.
*
* @param request Questions, owner agent, and abort signal.
* @returns The answer chosen or typed by the human.
* @throws {UserQuestionError} code `ASK_ABORTED` when the supplied signal
* is already or becomes aborted, `CALLER_NOT_LIVE` when a supplied agent
* is not the registry's exact live instance, or `DELEGATED_CALLER` when
* that live agent is owned by another agent.
*/
async ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>
Types: Agent · ToolCallId
Source: packages/interaction/user-questions/src/index.ts
user-questions/* events
user-questions/request — waterfall
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.
/**
* 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.
* @param request - pending user-question request.
* @mode waterfall
*/
'user-questions/request'( this: Scoped<Agent>, request: AskUserQuestionRequestEvent, next: () => Promise<AskUserQuestionAnswer>, ): Promise<AskUserQuestionAnswer>