用户交互

English | 中文

dsh-user-questions 的用户交互 seam。它是工具或权限插件需要人类回答后 agent(智能体)才能继续时所使用的、提供方无关的词汇。Agent-scoped waterfall listener 组合可用的 UI 界面,其中包括转发到已连接 client 的 listener。

源码:packages/interaction/user-questions/src/index.ts

问题选项

AskUserQuestionOption 包含一个可供选择的选项。label 是面向用户的选项文字,同时也是面向模型的选中值;description 是可选的 UI 帮助文本。

```ts type-equiv / 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` 的问题声明了意图。

```ts type-equiv
/**
 * 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 携带辅助文本;提供方会将其随问题渲染,但不会放入可选选项标签。

```ts type-equiv / 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。

```ts type-equiv
/** Request for a human answer. */
interface AskUserQuestionRequest extends AskUserQuestionRequestEvent {}

回答

提供方为每个问题 id 返回一个回答项。selected 包含选中的选项标签,custom 在用户输入自由文本时携带「其他」回答。对于单选题,custom 会覆盖选中的选项,且 selected 为空。对于多选题,custom 可以补充 selected 中的标签。UI 也可以使用 selected 为空且不含 custom 的回答项,在其余问题均已完成的批次中保留被跳过的问题。

```ts type-equiv / 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 }


```ts type-equiv
/** 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 的阻塞请求。

```ts type-equiv / 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 表示问题仍可回答,不是空答案、拒绝、确认或授权。

```ts type-equiv
/** 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 行无处可读用户最终的选择。排队的回复可在准入前取消,此时问题仍可回答。回复正文不含可读批次时,该调用以零答案结算;回复指向的调用已不在可回答集合中时不改变任何内容。

``ts type-equiv /**openwhile the tool call may still return the answer;continued` once the answer can only arrive as a new turn. */ type UserQuestionState = 'open' | 'continued'


```ts type-equiv
/** One unanswered timed `ask_user_question` call reconstructed from the Session log. */
interface PendingUserQuestion {
  readonly callId: ToolCallId
  readonly questions: readonly AskUserQuestionItem[]
  readonly state: UserQuestionState
}

``ts type-equiv /** * One timedask_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[] }


```ts type-equiv
/**
 * 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 侧取消。

```ts type-equiv /* 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' } }


<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->

<a id="cordis-surface"></a>

## 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](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).

<a id="ctxuserquestions--userquestionservice"></a>

### `ctx.userQuestions` — `UserQuestionService`

`ctx.userQuestions`: validation plus the scoped answerer waterfall.

```ts cordis-catalog
/**
 * 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.

ts cordis-catalog /** * 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>

Types: Agent · Scoped

Source: packages/interaction/user-questions/src/types.ts