Fix ask_user_question review findings
This commit is contained in:
@@ -463,7 +463,7 @@ registerProvider(provider: UserInteractionProvider): () => void
|
||||
async ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>
|
||||
```
|
||||
|
||||
Source: [`packages/core/user-interaction/src/index.ts:72`](../../packages/core/user-interaction/src/index.ts)
|
||||
Source: [`packages/core/user-interaction/src/index.ts:70`](../../packages/core/user-interaction/src/index.ts)
|
||||
|
||||
## Inherited tier (cordis core + loader/hmr/timer)
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t
|
||||
| [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnTrigger`/`TurnEndReason`, `deriveMessages()`, the turn-enclosure invariant |
|
||||
| [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` |
|
||||
| [tools.md](tools.md) | `ToolDefinition` full fields, the schema DSL, `ToolExecution`/`ToolResult`, tool-presentation UI types, the `tools/execute` waterfall |
|
||||
| [user-interaction.md](user-interaction.md) | the human question/answer seam: `AskUserQuestionRequest`/`Answer`, options, provider, structured errors |
|
||||
| [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, background `BashTask`s |
|
||||
| [subagent.md](subagent.md) | the subagent seam: the named-provider registry, `SubagentStartRequest`/`Result`/`Run`, the start-time-vs-runtime capability split |
|
||||
|
||||
|
||||
79
docs/core-data-structures/user-interaction.md
Normal file
79
docs/core-data-structures/user-interaction.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# User Interaction
|
||||
|
||||
The user-interaction seam of [dsh-user-interaction](../../packages/core/user-interaction). It is the provider-neutral vocabulary a tool or permission plugin uses when it needs the human to answer before the agent can continue. UI surfaces provide the active `UserInteractionProvider`: `dsh-ui-stdio` renders questions in readline, and `dsh-acp` maps them to ACP form elicitations.
|
||||
|
||||
Source: [`packages/core/user-interaction/src/index.ts`](../../packages/core/user-interaction/src/index.ts)
|
||||
|
||||
## Question options
|
||||
|
||||
`AskUserQuestionOption` is the selectable-choice shape. `label` is user-facing, while `value` is the model-facing answer returned when the option is selected; when omitted, providers use the label.
|
||||
|
||||
```ts type-equiv
|
||||
interface AskUserQuestionOption {
|
||||
/** User-facing label. */
|
||||
label: string
|
||||
/** Value returned to the model when selected. Defaults to `label`. */
|
||||
value?: string
|
||||
/** Optional extra context rendered by capable UIs. */
|
||||
description?: string
|
||||
/** Marks the recommended/default option. */
|
||||
recommended?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
## Ask request
|
||||
|
||||
`AskUserQuestionRequest` is the cross-package request. `options` being absent means free-form input; an optionless request remains free-form even when a caller sets `allowCustom: false`, because there is no selectable option to constrain the answer to.
|
||||
|
||||
```ts type-equiv
|
||||
interface AskUserQuestionRequest {
|
||||
/** The question to display. */
|
||||
question: string
|
||||
/** Optional short heading/group label. */
|
||||
header?: string
|
||||
/** Optional choices the UI can render as a menu. */
|
||||
options?: AskUserQuestionOption[]
|
||||
/** Whether free-form answers are accepted. Defaults to `true`. */
|
||||
allowCustom?: boolean
|
||||
/** Calling agent, when the request came from an agent tool call. */
|
||||
agent?: Agent
|
||||
/** Abort signal for the owning tool/step. */
|
||||
signal?: AbortSignal
|
||||
}
|
||||
```
|
||||
|
||||
## Answer
|
||||
|
||||
Providers return the model-facing `answer` text and optionally echo the chosen option as metadata. Consumers should use `answer`; the option is for UI/session metadata and diagnostics.
|
||||
|
||||
```ts type-equiv
|
||||
interface AskUserQuestionAnswer {
|
||||
/** Model-facing answer text. */
|
||||
answer: string
|
||||
/** The selected option, when the answer came from `options`. */
|
||||
option?: AskUserQuestionOption
|
||||
}
|
||||
```
|
||||
|
||||
## Provider
|
||||
|
||||
Only one provider may be active in a context. Provider registration is effect-bound so HMR/disposal removes the active UI.
|
||||
|
||||
```ts type-equiv
|
||||
interface UserInteractionProvider {
|
||||
ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>
|
||||
}
|
||||
```
|
||||
|
||||
## Errors
|
||||
|
||||
`UserInteractionError` extends `HarnessError`, so `ctx.tools.execute()` preserves `{ name, code }` for model-facing tool failures such as `NO_PROVIDER`, `ASK_ABORTED`, or ACP-side cancellation.
|
||||
|
||||
```ts type-equiv
|
||||
class UserInteractionError extends HarnessError {
|
||||
constructor(message: string, code: string, options?: ErrorOptions) {
|
||||
super(message, code, options)
|
||||
this.name = 'UserInteractionError'
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -32,11 +32,13 @@ graph TD
|
||||
tools --> llm
|
||||
tools --> system-prompt
|
||||
user-interaction --> agent
|
||||
user-interaction --> llm
|
||||
acp --> agent
|
||||
acp --> llm
|
||||
acp --> session
|
||||
acp --> session-persistence
|
||||
acp --> tools
|
||||
acp --> user-interaction
|
||||
agent-loop --> agent
|
||||
agent-loop --> llm
|
||||
agent-loop --> session
|
||||
@@ -81,6 +83,8 @@ graph TD
|
||||
acp-agent --> acp
|
||||
acp-agent --> agent-core
|
||||
acp-agent --> session-persistence-jsonl
|
||||
acp-agent --> tool-ask-user
|
||||
acp-agent --> user-interaction
|
||||
stdio-agent --> agent
|
||||
stdio-agent --> agent-core
|
||||
stdio-agent --> session
|
||||
@@ -113,8 +117,8 @@ graph TD
|
||||
| `session-persistence-jsonl` | `session`, `session-persistence` |
|
||||
| `session-persistence-sqlite` | `session`, `session-persistence` |
|
||||
| `tools` | `agent`, `llm`, `system-prompt` |
|
||||
| `user-interaction` | `agent` |
|
||||
| `acp` | `agent`, `llm`, `session`, `session-persistence`, `tools` |
|
||||
| `user-interaction` | `agent`, `llm` |
|
||||
| `acp` | `agent`, `llm`, `session`, `session-persistence`, `tools`, `user-interaction` |
|
||||
| `agent-loop` | `agent`, `llm`, `session`, `session-persistence`, `system-prompt`, `tools` |
|
||||
| `subagent` | `agent`, `llm`, `tools` |
|
||||
| `tool-ask-user` | `agent`, `tools`, `user-interaction` |
|
||||
@@ -125,7 +129,7 @@ graph TD
|
||||
| `subagent-inprocess` | `agent`, `llm`, `session`, `subagent` |
|
||||
| `subagent-mock` | `agent`, `llm`, `subagent` |
|
||||
| `tool-subagent` | `agent`, `llm`, `subagent`, `tools` |
|
||||
| `acp-agent` | `acp`, `agent-core`, `session-persistence-jsonl` |
|
||||
| `acp-agent` | `acp`, `agent-core`, `session-persistence-jsonl`, `tool-ask-user`, `user-interaction` |
|
||||
| `stdio-agent` | `agent`, `agent-core`, `session`, `session-persistence-jsonl`, `tool-ask-user`, `ui-stdio`, `user-interaction` |
|
||||
| `subagent-fork` | `agent`, `session`, `subagent`, `subagent-inprocess` |
|
||||
| `subagent-spawn` | `subagent`, `subagent-inprocess` |
|
||||
|
||||
@@ -84,6 +84,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r
|
||||
| [Rich ACP bash rendering — the terminal card (`_meta`) and command classification](implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) | 2026-06-18 |
|
||||
| [Subagent capability seam](implemented/feature/2026-06-21-subagent-capability-seam.md) | 2026-06-21 |
|
||||
| [ACP subagent backend (out-of-process delegation)](implemented/feature/2026-06-22-acp-subagent-backend.md) | 2026-06-22 |
|
||||
| [Ask-user question capability](implemented/feature/2026-06-25-ask-user-question.md) | 2026-06-25 |
|
||||
|
||||
### Simplification
|
||||
|
||||
|
||||
39
docs/rfc/implemented/feature/2026-06-25-ask-user-question.md
Normal file
39
docs/rfc/implemented/feature/2026-06-25-ask-user-question.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# RFC: Ask-user question capability
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
The agent sometimes cannot proceed safely from model inference alone: it needs the human to choose a path, confirm a risky/default action, or provide missing information. Before this change, the only way to get that answer was for the model to ask in assistant text and then stop, which broke the normal tool-call loop: the agent had no structured way to pause, no option metadata for UIs, no abort/error taxonomy, and no way for non-stdio front doors to present the question consistently.
|
||||
|
||||
This is a user-facing capability, but it also crosses package boundaries. A model-facing tool needs a provider-neutral request vocabulary; each UI surface needs to decide how to show and collect the answer; the agent loop should remain unchanged because a tool call already has the right async shape.
|
||||
|
||||
## Decision
|
||||
|
||||
Introduce `dsh-user-interaction` as the core interface package for `ctx.userInteraction`, and keep the model-facing consumer `dsh-tool-ask-user` under `packages/ui/tool-ask-user` rather than the core spine. The split is intentional: core owns the abstract seam and stable request/answer/error vocabulary; UI product surfaces own the affordance that asks a human and the concrete provider that collects the answer. The tool registers `ask_user_question`, forwards `{ question, header, options, allowCustom, agent, signal }`, and returns the provider-computed `answer` as the tool result.
|
||||
|
||||
The request vocabulary supports a short `header`, the required `question`, optional mutually exclusive `options`, `description` for each option, a `recommended` marker, and `allowCustom`. `label` is user-facing display text; `value` is the model-facing answer for a selected option and defaults to `label`. Providers return `AskUserQuestionAnswer.answer` as the single source of truth; the selected `option` is metadata. The tool schema exposes `description` only, not the synonym `desc`, to keep the model-facing surface small.
|
||||
|
||||
Optionless questions are always free-form, even if a caller passes `allowCustom: false`. The opposite would create an unanswerable prompt: with no option to select and free-form input disallowed, every human answer would be rejected forever. Providers therefore treat "no options" as the free-form shape.
|
||||
|
||||
`UserInteractionError` extends `HarnessError`, so failures such as `NO_PROVIDER`, `ASK_ABORTED`, ACP cancellation, or missing session routing survive `ctx.tools.execute()` as machine-routable `{ name, code }` tool errors. This matches the structured-error taxonomy and lets the model or a wrapping plugin distinguish "user cancelled" from a generic thrown exception.
|
||||
|
||||
## UI mappings
|
||||
|
||||
`dsh-ui-stdio` renders the question in readline, sorts recommended options first, shows each option's `description` on the next line, accepts the recommended option on an empty answer, and rejects pending questions on abort, provider disposal, or stdin EOF. The stdio provider serializes multiple simultaneous questions with an internal queue so only one prompt owns stdin at a time.
|
||||
|
||||
`dsh-acp` provides the same seam for ACP sessions. It routes an ask request from the calling `Agent` through the bridge's `agent→sessionId` reverse map and calls ACP `unstable_createElicitation` with a session-scoped form. Option choices become a `choice` single-select field with the recommended option as the schema default; free-form answers use `answer` for optionless questions and `custom_answer` when options plus custom input are allowed. ACP `decline`/`cancel`, a missing answer, a missing session, and a client without elicitation support all become structured `UserInteractionError`s.
|
||||
|
||||
The ACP mapping deliberately uses elicitation, not `session/request_permission`. `request_permission` is still reserved for the separate permission gate: it is a yes/no-or-policy authorization protocol around tool execution. `ask_user_question` is a general information-gathering tool with optional free-form answers, so ACP form elicitation is the closer protocol fit. The bridge's session routing is shared with the future permission gate, but the user intent is different.
|
||||
|
||||
## Risks / trade-offs
|
||||
|
||||
ACP elicitation is currently marked unstable in the SDK. The fallback is still structured: if a client does not implement it, the tool returns `ASK_FAILED` rather than hanging. A later ACP stabilization may rename or reshape the method; that migration should stay inside `dsh-acp` because the core `ctx.userInteraction` vocabulary is provider-neutral.
|
||||
|
||||
The feature gives the model a powerful pause primitive, so prompt guidance matters. The tool description tells the model to ask concise questions and use options when possible. Product policy can later wrap `tools/execute` to restrict when the tool is allowed, but the loop should not special-case it.
|
||||
|
||||
`dsh-tool-ask-user` lives in `packages/ui` even though it is a tool, because it is a product-facing human-interaction affordance rather than providerless loop infrastructure. The core package remains only the abstract seam; `agent-core` does not load the tool. Front-door app packages such as `stdio-agent` and `acp-agent` opt into it alongside their UI provider.
|
||||
|
||||
## Test plan
|
||||
|
||||
Unit coverage pins provider registration/disposal, duplicate-provider rejection, abort-before-provider, structured tool errors through `ctx.tools.execute()`, option labels/values, and the model schema including the removal of `desc`. `dsh-ui-stdio` tests cover recommended-first display, descriptions, queued questions, EOF/abort cleanup, and optionless free-form input even with `allowCustom: false`. ACP bridge tests drive a real in-memory ACP connection with the real `ask_user_question` tool and verify both selected-option and optionless free-form elicitation paths continue the agent loop.
|
||||
Reference in New Issue
Block a user