diff --git a/AGENTS.md b/AGENTS.md index c272ad5dda..02615f53b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,7 @@ packages/ Harness packages at packages///, all named @deepseek-ai cordis/ self-referential toolset: the agent inspects/mounts plugins in its own runtime hooks/ Claude Code / Codex hook bridges + shared wire-protocol library session-persistence/ persistence seam + JSONL/SQLite backends - ui/ ACP bridge + app-boot glue + the stdio/ACP app bins + ui/ ACP bridge, app-boot glue, stdio/ACP app bins, user-interaction seam, ask-user tool support/ dev/test infrastructure packages util/ zero-dependency utilities examples/ Runnable demos: thin cordis.yml leaves over the app packages (see examples/AGENTS.md) diff --git a/docs/capability-seams.md b/docs/capability-seams.md index b85fa40305..a0bfba136a 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -30,12 +30,15 @@ flowchart LR pkg_tool_fs["tool-fs"] pkg_tool_web["tool-web"] svc_tools["ctx.tools
Tool registry and execution waterfall"] + pkg_tool_ask_user["tool-ask-user"] pkg_tool_bash["tool-bash"] pkg_tool_cordis["tool-cordis"] pkg_tool_subagent["tool-subagent"] pkg_tool_todo["tool-todo"] - svc_agents["ctx.agents
Agent registry"] + pkg_user_interaction["user-interaction"] + svc_userInteraction["ctx.userInteraction
Human question/answer seam"] pkg_stdio_agent["stdio-agent"] + svc_agents["ctx.agents
Agent registry"] svc_agentLoop["ctx.agentLoop
Concrete loop driver"] pkg_agent_core["agent-core"] pkg_bash["bash"] @@ -64,6 +67,7 @@ flowchart LR pkg_web_search_perplexity["web-search-perplexity"] pkg_web_search_deepseek["web-search-deepseek"] pkg_web_fetch_local["web-fetch-local"] + pkg_acp --> svc_userInteraction pkg_agent --> svc_agents pkg_agent_loop --> svc_agentLoop pkg_bash --> svc_bash @@ -82,6 +86,7 @@ flowchart LR pkg_session_persistence --> svc_sessionPersistence pkg_session_persistence_jsonl --> svc_sessionPersistence pkg_session_persistence_sqlite --> svc_sessionPersistence + pkg_stdio_agent --> svc_userInteraction pkg_subagent --> svc_subagents pkg_subagent_acp --> svc_subagents pkg_subagent_fork --> svc_subagents @@ -89,6 +94,7 @@ flowchart LR pkg_subagent_spawn --> svc_subagents pkg_system_prompt --> svc_systemPrompt pkg_tools --> svc_tools + pkg_user_interaction --> svc_userInteraction pkg_web --> svc_web pkg_web_fetch_local --> svc_web pkg_web_search_deepseek --> svc_web @@ -121,12 +127,16 @@ flowchart LR svc_systemPrompt --> pkg_tools svc_tools --> pkg_acp svc_tools --> pkg_agent_loop + svc_tools --> pkg_tool_ask_user svc_tools --> pkg_tool_bash svc_tools --> pkg_tool_cordis svc_tools --> pkg_tool_fs svc_tools --> pkg_tool_subagent svc_tools --> pkg_tool_todo svc_tools --> pkg_tool_web + svc_userInteraction --> pkg_acp + svc_userInteraction --> pkg_stdio_agent + svc_userInteraction --> pkg_tool_ask_user svc_web --> pkg_tool_web svc_fs -. event gate .-> pkg_fs_policy ``` @@ -137,7 +147,8 @@ flowchart LR | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. | -| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web), [`acp`](../packages/ui/acp) | - | Registers tool definitions, exposes schemas to the prompt, and routes calls through tools/pre-execute and tools/post-execute. | +| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web), [`acp`](../packages/ui/acp) | - | Registers tool definitions, exposes schemas to the prompt, and routes calls through tools/pre-execute and tools/post-execute. | +| `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | [`stdio-agent`](../packages/ui/stdio-agent), [`acp`](../packages/ui/acp) | [`tool-ask-user`](../packages/ui/tool-ask-user), [`stdio-agent`](../packages/ui/stdio-agent), [`acp`](../packages/ui/acp) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. | | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`stdio-agent`](../packages/ui/stdio-agent), [`invariants`](../packages/support/invariants) | - | Owns live Agent handles and the create/resume factory seam. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-core`](../packages/core/agent-core) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors can replace bash-local. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 2e6be3a1ea..e22dff302b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -11,7 +11,7 @@ A `Requires:` line lists the service keys the plugin `inject`s: its `cordis.yml` ## `@deepseek-ai/dsh-acp` -Requires: `agents` · `sessions` · `sessionPersistence` · `tools` +Requires: `agents` · `sessions` · `sessionPersistence` · `tools` · `userInteraction` ```ts config-catalog /** Plugin config: the agent template ACP sessions are created from. */ @@ -31,7 +31,7 @@ export interface AcpConfig { Depends on: `Stream` (`@agentclientprotocol/sdk`) -Source: [`packages/ui/acp/src/index.ts:115`](../packages/ui/acp/src/index.ts) +Source: [`packages/ui/acp/src/index.ts:236`](../packages/ui/acp/src/index.ts) ## `@deepseek-ai/dsh-acp-agent` @@ -56,7 +56,7 @@ export interface Config { } ``` -Source: [`packages/ui/acp-agent/src/index.ts:49`](../packages/ui/acp-agent/src/index.ts) +Source: [`packages/ui/acp-agent/src/index.ts:50`](../packages/ui/acp-agent/src/index.ts) ## `@deepseek-ai/dsh-agent-core` @@ -505,7 +505,7 @@ export interface Config { } ``` -Source: [`packages/ui/stdio-agent/src/index.ts:60`](../packages/ui/stdio-agent/src/index.ts) +Source: [`packages/ui/stdio-agent/src/index.ts:62`](../packages/ui/stdio-agent/src/index.ts) ## `@deepseek-ai/dsh-subagent-acp` @@ -884,9 +884,11 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) - `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts)) - `@deepseek-ai/dsh-timeout-policy` — requires `tools` ([`packages/timeout/timeout-policy/src/index.ts`](../packages/timeout/timeout-policy/src/index.ts)) +- `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userInteraction` ([`packages/ui/tool-ask-user/src/index.ts`](../packages/ui/tool-ask-user/src/index.ts)) - `@deepseek-ai/dsh-tool-bash` — requires `tools` · `bash` · `systemPrompt` ([`packages/bash/tool-bash/src/index.ts`](../packages/bash/tool-bash/src/index.ts)) - `@deepseek-ai/dsh-tool-todo` — requires `tools` ([`packages/todo/tool-todo/src/index.ts`](../packages/todo/tool-todo/src/index.ts)) - `@deepseek-ai/dsh-tools` — requires `systemPrompt` ([`packages/core/tools/src/index.ts`](../packages/core/tools/src/index.ts)) +- `@deepseek-ai/dsh-user-interaction` ([`packages/ui/user-interaction/src/index.ts`](../packages/ui/user-interaction/src/index.ts)) ## Seam packages (not directly loadable) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 5f4fa4ff78..968dcd8068 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -226,6 +226,17 @@ Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecution](../ Source: [`packages/core/tools/src/index.ts:307`](../../packages/core/tools/src/index.ts) +## `ctx.userInteraction` — `UserInteractionService` + +`ctx.userInteraction`: one active UI provider plus an `ask()` surface. + +```ts cordis-catalog +registerProvider(provider: UserInteractionProvider): () => void +async ask(request: AskUserQuestionRequest): Promise +``` + +Source: [`packages/ui/user-interaction/src/index.ts:82`](../../packages/ui/user-interaction/src/index.ts) + ## `ctx.web` — `WebService` The web access service. Registered as `ctx.web` (one instance per context). diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 615c222d94..128576c5e1 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -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/pre-execute`/`tools/post-execute` pipeline | +| [user-interaction.md](user-interaction.md) | the UI-backed human question/answer seam: `AskUserQuestionRequest`, answer/options vocabulary, provider API, error taxonomy | | [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, background `BashTask`s | | [code-runtime.md](code-runtime.md) | the code-execution seam: `CodeRunRequest`/`Result`, binding namespaces, captured logs, the `CodeRunFailure` taxonomy | | [filesystem.md](filesystem.md) | the filesystem seam: `FsTarget`, read/write/edit outcomes, observed-file state, `FsErrorCode` | diff --git a/docs/core-data-structures/user-interaction.md b/docs/core-data-structures/user-interaction.md new file mode 100644 index 0000000000..6155fd9896 --- /dev/null +++ b/docs/core-data-structures/user-interaction.md @@ -0,0 +1,97 @@ +# User Interaction + +The user-interaction seam of [dsh-user-interaction](../../packages/ui/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-stdio-agent` renders questions in readline, and `dsh-acp` maps them to ACP form elicitations. + +Source: [`packages/ui/user-interaction/src/index.ts`](../../packages/ui/user-interaction/src/index.ts) + +## Question options + +`AskUserQuestionOption` is the selectable-choice shape. `label` is the user-facing option text and also the model-facing selected value; `description` is optional UI help text. + +```ts type-equiv +interface AskUserQuestionOption { + /** User-facing label. */ + label: string + /** Optional extra context rendered by capable UIs. */ + description?: string +} +``` + +## Question item + +`AskUserQuestionItem` is one question in a request. The model supplies a stable `id`, which is echoed back with the answer so batched questions remain routable. + +```ts type-equiv +interface AskUserQuestionItem { + /** Stable model-provided question id, echoed in the answer. */ + id: string + /** 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 more than one option may be selected. Defaults to single-select. */ + multiSelect?: boolean +} +``` + +## Ask request + +`AskUserQuestionRequest` is the cross-package request. `questions` is an array so a UI can present related prompts in one flow while preserving a stable id per answer. + +```ts type-equiv +interface AskUserQuestionRequest { + /** Questions to display. */ + questions: AskUserQuestionItem[] + /** 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 one answer per answered question id. `selected` contains selected option labels, and `custom` carries a free-form "Other" answer when the user typed one. When `custom` is present, `selected` is empty; custom text is an answer override, not a supplement to selected choices. + +```ts type-equiv +interface AskUserQuestionAnswerItem { + /** The answered question id. */ + id: string + /** Selected option labels. Empty when the answer is purely custom text. */ + selected: string[] + /** Optional free-text "Other" answer. */ + custom?: string +} +``` + +```ts type-equiv +interface AskUserQuestionAnswer { + /** Structured answers keyed by question id. */ + answers: AskUserQuestionAnswerItem[] +} +``` + +## 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 +} +``` + +## Errors + +`UserInteractionError` extends `HarnessError`, so `ctx.tools.execute()` preserves `{ name, code }` for model-facing tool failures such as `EMPTY_QUESTIONS`, `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' + } +} +``` diff --git a/docs/module-graph.md b/docs/module-graph.md index beabf14c69..0c6a24ce83 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -85,6 +85,8 @@ flowchart TD pkg_acp_agent["acp-agent"] pkg_app_boot["app-boot"] pkg_stdio_agent["stdio-agent"] + pkg_tool_ask_user["tool-ask-user"] + pkg_user_interaction["user-interaction"] end subgraph group_code_runtime["packages/code-runtime"] pkg_code_runtime["code-runtime"] @@ -138,6 +140,8 @@ flowchart TD pkg_invariants --> pkg_agent pkg_invariants --> pkg_llm pkg_invariants --> pkg_session + pkg_user_interaction --> pkg_agent + pkg_user_interaction --> pkg_llm pkg_agent_loop --> pkg_agent pkg_agent_loop --> pkg_llm pkg_agent_loop --> pkg_session @@ -178,6 +182,10 @@ flowchart TD pkg_acp --> pkg_session pkg_acp --> pkg_session_persistence pkg_acp --> pkg_tools + pkg_acp --> pkg_user_interaction + pkg_tool_ask_user --> pkg_agent + pkg_tool_ask_user --> pkg_tools + pkg_tool_ask_user --> pkg_user_interaction pkg_repeat_tool_guard --> pkg_agent pkg_repeat_tool_guard --> pkg_tools pkg_agent_core --> pkg_agent @@ -220,12 +228,15 @@ flowchart TD pkg_acp_agent --> pkg_agent_core pkg_acp_agent --> pkg_app_boot pkg_acp_agent --> pkg_session_persistence_jsonl + pkg_acp_agent --> pkg_user_interaction pkg_stdio_agent --> pkg_agent pkg_stdio_agent --> pkg_agent_core pkg_stdio_agent --> pkg_app_boot pkg_stdio_agent --> pkg_llm pkg_stdio_agent --> pkg_session pkg_stdio_agent --> pkg_session_persistence_jsonl + pkg_stdio_agent --> pkg_tool_ask_user + pkg_stdio_agent --> pkg_user_interaction ``` | Package | Group | Depends on | @@ -261,6 +272,7 @@ flowchart TD | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | [`invariants`](../packages/support/invariants) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -270,7 +282,8 @@ flowchart TD | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`tool-cordis`](../packages/cordis/tool-cordis) | `cordis` | [`tools`](../packages/core/tools) | | [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | -| [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) | +| [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) | | [`agent-core`](../packages/core/agent-core) | `core` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/bash/tool-bash), [`tools`](../packages/core/tools) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent) | @@ -280,5 +293,5 @@ flowchart TD | [`subagent-mock`](../packages/support/subagent-mock) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | -| [`acp-agent`](../packages/ui/acp-agent) | `ui` | [`acp`](../packages/ui/acp), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | -| [`stdio-agent`](../packages/ui/stdio-agent) | `ui` | [`agent`](../packages/core/agent), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | +| [`acp-agent`](../packages/ui/acp-agent) | `ui` | [`acp`](../packages/ui/acp), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`user-interaction`](../packages/ui/user-interaction) | +| [`stdio-agent`](../packages/ui/stdio-agent) | `ui` | [`agent`](../packages/core/agent), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tool-ask-user`](../packages/ui/tool-ask-user), [`user-interaction`](../packages/ui/user-interaction) | diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 09f8caedb4..6f569334f2 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -55,6 +55,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Compaction as a capability seam (abstract contract + basic backend)](implemented/feature/2026-06-18-compaction-capability-seam.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 | | [The `todo_write` tool — model task list as event-sourced session state](implemented/feature/2026-06-29-todo-write-tool.md) | 2026-06-29 | | [dsh-hooks-claude + dsh-hooks-codex — the Claude Code / Codex hook bridges](implemented/feature/2026-06-30-hook-bridges.md) | 2026-06-30 | | [dsh-hook-protocol — the shared Claude Code / Codex hook wire-protocol core](implemented/feature/2026-06-30-hook-protocol-lib.md) | 2026-06-30 | diff --git a/docs/rfc/implemented/feature/2026-06-25-ask-user-question.md b/docs/rfc/implemented/feature/2026-06-25-ask-user-question.md new file mode 100644 index 0000000000..9320189de1 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-25-ask-user-question.md @@ -0,0 +1,49 @@ +# 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 provider-neutral interface package for `ctx.userInteraction`, colocated with the model-facing consumer `dsh-tool-ask-user` under `packages/ui`. The grouping is intentional: asking a human is a UI-backed product affordance, not part of the providerless core spine. The seam still owns the stable request/answer/error vocabulary, while UI product surfaces provide the concrete provider that collects the answer. The tool registers `ask_user_question`, forwards `{ questions, agent, signal }`, and returns the provider-computed structured answers as the tool result. + +The model-facing request vocabulary is deliberately aligned with the product-research schema: `ask_user_question({ questions: [{ id, question, header?, options?: [{ label, description? }], multi_select? }] })`. `id` is supplied per question and echoed in the result so a batch can be routed without relying on question text. `label` is both user-facing display text and the selected value returned to the model; there is no separate `value`, no `recommended`, no `allow_custom`, and no `desc` alias. + +Providers return `{ answers: [{ id, selected, custom? }] }`. `selected` is always an array of selected option labels, so single-select and `multi_select` answers share one result shape. `custom` carries a free-text "Other" answer; optionless questions collect `custom` directly. When `custom` is present, it overrides any selected choices and `selected` is empty. + +`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-stdio-agent`'s in-package readline module renders each question, shows each option's `description` on the next line, supports comma/space-separated numeric choices for `multi_select`, accepts free-form custom answers, and rejects pending questions on abort, provider disposal, or stdin EOF. A batched request is asked in order and resolved as one answer object. The stdio provider serializes simultaneous requests 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 for each question. Single-select options become a `choice` string enum; `multi_select` options become a `choice` array enum; optionless questions use a required `custom` text field. If the client returns both `choice` and non-empty `custom`, the custom answer wins. 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. + +## Alternatives considered + +**Assistant text followed by a stopped turn.** The model could ask the user in plain assistant text and then stop. That loses the structured option metadata, gives UIs no provider-neutral way to render a choice, and forces the next human answer to arrive as a new user prompt rather than as the result of the operation that needed the answer. + +**Core-owned ask-user packages.** The first implementation split the seam and the model-facing tool across `packages/core` and `packages/ui`, but both names describe one UI-backed human-interaction affordance. The seam remains provider-neutral, but it is not providerless core infrastructure like sessions, tools, or the agent registry. Keeping `dsh-user-interaction` and `dsh-tool-ask-user` together under `packages/ui` makes the package map match the product boundary: apps and bridges provide the human-answer provider, and the stdio app opts into the model-facing tool. + +**ACP `session/request_permission`.** Permission requests are authorization around tool execution; `ask_user_question` is information gathering with optional free-form answers. Using permission for general questions would collapse two different product concepts and make the future permission gate harder to reason about. + +**A loop-level pause primitive.** The agent loop already knows how to await a tool call and resume from a tool result. Adding a new loop special case would duplicate that async shape and make every loop implementation learn about a UI concern. + +## Consequences + +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-user-interaction` and `dsh-tool-ask-user` both live in `packages/ui` because they form one product-facing human-interaction capability. `agent-core` does not load either the tool or a provider. `stdio-agent` opts into the seam, its readline provider, and the model-facing tool. `acp-agent` keeps only the `userInteraction` seam/provider by default: ACP elicitation support is still client-dependent, so an ACP leaf must opt into the model-facing tool deliberately once its client can complete elicitation requests. + +## Testing + +Unit coverage pins provider registration/disposal, duplicate-provider rejection, abort-before-provider, empty-question rejection, structured tool errors through `ctx.tools.execute()`, batched answers, multi-select answers, custom answers, and the model schema including the removal of `value`, `recommended`, `allow_custom`, and `desc`. `dsh-stdio-agent` tests cover option descriptions, queued requests, EOF/abort cleanup, optionless free-form input, invalid option reprompts, duplicate multi-select numbers, and batched question flows. ACP bridge tests drive a real in-memory ACP connection with the real `ask_user_question` tool and verify selected-option, custom-overrides-choice, multi-select, and optionless free-form elicitation paths continue the agent loop. diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index a2026904cf..9a26078091 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -15,6 +15,7 @@ This table connects model-visible tool names to the plugin package and service s | Tool package | Model-visible names | Requires | Writes / affects | Shipped aliases | Deployment note | | --- | --- | --- | --- | --- | --- | +| `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`, `ctx.userInteraction` | `tool/call`, `tool/result after a UI/provider answers the question` | - | ask_user_question pauses the tool call until the active UI provider returns a human answer. | | `@deepseek-ai/dsh-tool-bash` | `bash`, `bash_kill`, `bash_output` | `ctx.tools`, `ctx.bash` | `tool/call`, `tool/result`, `context/message via agent.inject() for background completion notices` | - | The bash/bash_output/bash_kill tools are model-facing consumers of the bash executor seam. | | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `live plugin-tree mutations (mount/unmount)` | - | Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; the request-header ToolsDelta logs those tool-set changes. | | `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. | @@ -22,6 +23,76 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist or ACP plan. | | `@deepseek-ai/dsh-tool-web` | `web_fetch`, `web_search` | `ctx.tools`, `ctx.web`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | web_search and web_fetch keep provider selection behind ctx.web so model-visible schemas stay stable across backend swaps. | +## `@deepseek-ai/dsh-tool-ask-user` + +### `ask_user_question` + +Ask the user a concise question when you need confirmation, a choice, or missing information before proceeding. Send one or more questions, each with a stable id that will be echoed in the answer. + +```json +{ + "type": "object", + "properties": { + "questions": { + "type": "array", + "description": "Questions to ask the user before continuing.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Stable id for this question; echoed in the answer." + }, + "question": { + "type": "string", + "description": "The specific question to ask the user." + }, + "header": { + "type": "string", + "description": "Optional short heading for the question, such as \"Confirm\" or \"Choose Mode\"." + }, + "options": { + "type": "array", + "description": "Optional choices to show the user. If you recommend one, put it first and append \"(Recommended)\" to that label.", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "description": "Short user-facing option label." + }, + "description": { + "type": "string", + "description": "One sentence explaining the tradeoff or impact." + } + }, + "required": [ + "label" + ] + } + }, + "multi_select": { + "type": "boolean", + "description": "Whether the user may select more than one option. Defaults to false." + } + }, + "required": [ + "id", + "question" + ] + } + } + }, + "required": [ + "questions" + ] +} +``` + +Source: [`packages/ui/tool-ask-user/src/index.ts`](../packages/ui/tool-ask-user/src/index.ts) + +ask_user_question pauses the tool call until the active UI provider returns a human answer. + ## `@deepseek-ai/dsh-tool-bash` ### `bash` diff --git a/packages/README.md b/packages/README.md index d7c3c4caf1..9f6daaabe1 100644 --- a/packages/README.md +++ b/packages/README.md @@ -22,7 +22,7 @@ Packages are grouped by modular role at `packages///`. The group dir | [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface | | [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface | | [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface | -| [`ui/`](ui/README.md) | Editor/client integration surfaces (the ACP bridge) + the app packages | Product — stable surface | +| [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, app packages, user-interaction seam, ask-user tool | Product — stable surface | | [`support/`](support/README.md) | Dev/test/example infrastructure (invariants, replay adapter, subagent mock) | Support — lower compatibility expectations | | [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (the `Branded` primitive) | Support — small, stable, harness-dep-free | diff --git a/packages/bash/bash-local/tests/executor.spec.ts b/packages/bash/bash-local/tests/executor.spec.ts index 98095b8d47..450ad7a81f 100644 --- a/packages/bash/bash-local/tests/executor.spec.ts +++ b/packages/bash/bash-local/tests/executor.spec.ts @@ -40,12 +40,14 @@ async function readUntil( ): Promise { const deadline = Date.now() + timeoutMs let last: BashTaskRead | undefined + let delta = '' while (Date.now() < deadline) { last = bash.readOutput(id) - if (last.delta.includes(expected)) return last + delta += last.delta + if (delta.includes(expected)) return { ...last, delta } await new Promise(resolve => setTimeout(resolve, 20)) } - throw new Error(`task ${id} output did not include ${JSON.stringify(expected)}; last delta was ${JSON.stringify(last?.delta ?? '')}`) + throw new Error(`task ${id} output did not include ${JSON.stringify(expected)}; output was ${JSON.stringify(delta)}, last delta was ${JSON.stringify(last?.delta ?? '')}`) } describe('LocalBashExecutor.run', () => { diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 83170d8dc1..e75384db68 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -178,6 +178,14 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ 'async execute(exec: ToolExecution): Promise', ], }, + { + key: 'userInteraction', + summary: '`ctx.userInteraction`: one active UI provider plus an `ask()` surface.', + methods: [ + 'registerProvider(provider: UserInteractionProvider): () => void', + 'async ask(request: AskUserQuestionRequest): Promise', + ], + }, { key: 'web', summary: 'The web access service.', @@ -388,6 +396,26 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AgentStatus', declaration: 'export type AgentStatus = \'idle\' | \'running\' | \'disposed\';', }, + { + name: 'AskUserQuestionAnswer', + declaration: 'export interface AskUserQuestionAnswer {\n answers: AskUserQuestionAnswerItem[];\n}', + }, + { + name: 'AskUserQuestionAnswerItem', + declaration: 'export interface AskUserQuestionAnswerItem {\n id: string;\n selected: string[];\n custom?: string;\n}', + }, + { + name: 'AskUserQuestionItem', + declaration: 'export interface AskUserQuestionItem {\n id: string;\n question: string;\n header?: string;\n options?: AskUserQuestionOption[];\n multiSelect?: boolean;\n}', + }, + { + name: 'AskUserQuestionOption', + declaration: 'export interface AskUserQuestionOption {\n label: string;\n description?: string;\n}', + }, + { + name: 'AskUserQuestionRequest', + declaration: 'export interface AskUserQuestionRequest {\n questions: AskUserQuestionItem[];\n agent?: Agent;\n signal?: AbortSignal;\n}', + }, { name: 'AssembleContext', declaration: 'export interface AssembleContext {\n}', @@ -760,6 +788,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'TurnTriggerMap', declaration: 'export interface TurnTriggerMap {\n message: {\n kind: \'message\';\n source: MessageSource;\n };\n injection: {\n kind: \'injection\';\n source: MessageSource;\n };\n}', }, + { + name: 'UserInteractionProvider', + declaration: 'export interface UserInteractionProvider {\n ask(request: AskUserQuestionRequest): Promise;\n}', + }, { name: 'WebExecContext', declaration: 'export interface WebExecContext {\n readonly signal?: AbortSignal;\n}', diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index d4a6b97243..3754e93abc 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -35,7 +35,7 @@ describe('gen-tool-catalog collectToolCatalog', () => { it('boots every shipped tool package and harvests its model-facing schemas', async () => { const catalog = await collectToolCatalog() const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort() - expect(names).toEqual(['bash', 'bash_kill', 'bash_output', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'edit', 'read', 'subagent', 'todo_write', 'web_fetch', 'web_search', 'write']) + expect(names).toEqual(['ask_user_question', 'bash', 'bash_kill', 'bash_output', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'edit', 'read', 'subagent', 'todo_write', 'web_fetch', 'web_search', 'write']) // Every tool carries a JSON-Schema `parameters` object (what the model sees). for (const entry of catalog) { for (const schema of entry.schemas) { diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index 98be207159..cad484640b 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -5,6 +5,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool, schemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError, type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision, + type ToolExecution, type ToolExecutionResult, } from '@deepseek-ai/dsh-tools' async function setup() { @@ -297,7 +298,7 @@ describe('ToolRegistry', () => { })) ctx.on('tools/pre-execute', async (_exec, next) => { order.push('pre'); return next() }) - ctx.on('tools/execute', async (_exec, next) => { + ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise): Promise => { order.push('execute:before') const result = await next() order.push('execute:after') @@ -317,7 +318,10 @@ describe('ToolRegistry', () => { let entered = false ctx.on('tools/pre-execute', async (_exec, _next): Promise => ({ kind: 'deny', reason: 'nope' })) - ctx.on('tools/execute', async (_exec, next) => { entered = true; return next() }) + ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise): Promise => { + entered = true + return next() + }) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result.isError).toBe(true) @@ -334,7 +338,7 @@ describe('ToolRegistry', () => { }) let seen: { isError: boolean; error?: unknown } | undefined - ctx.on('tools/execute', async (_exec, next) => { + ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise): Promise => { const result = await next() // The base next() IS dispatch-with-normalization: the wrapper sees the // normalized isError result, never a raw throw from the tool body. @@ -357,7 +361,7 @@ describe('ToolRegistry', () => { }) let postSaw: boolean | undefined - ctx.on('tools/execute', async (_exec, next) => next()) + ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise): Promise => next()) ctx.on('tools/post-execute', async (_exec, result, next) => { postSaw = result.isError return next() @@ -383,7 +387,7 @@ describe('ToolRegistry', () => { const upstream = new AbortController().signal const replacement = new AbortController().signal - ctx.on('tools/execute', async (exec, next) => { + ctx.on('tools/execute', async (exec: ToolExecution, next: () => Promise): Promise => { expect(exec.signal).toBe(upstream) // Cordis next() ignores passed arguments, so a wrapper mutates exec in // place (the documented "mutate the shared object, then delegate" idiom). @@ -404,7 +408,7 @@ describe('ToolRegistry', () => { async execute() { dispatched = true; return [] }, }) - ctx.on('tools/execute', async (exec, _next): Promise => + ctx.on('tools/execute', async (exec: ToolExecution, _next: () => Promise): Promise => ({ callId: exec.callId, content: [{ type: 'text', text: 'short-circuited' }], isError: false })) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'never-runs', arguments: {} }) diff --git a/packages/ui/README.md b/packages/ui/README.md index 8b0293cf9e..e87a5e4a10 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -5,10 +5,14 @@ Integrations that expose the agent to an external editor or client. These are ** | Package | Role | ctx key | |---|---|---| | `acp/` | Agent Client Protocol bridge: serves the agent to an ACP editor (Zed) over JSON-RPC stdio | (drives `ctx.agents`/`ctx.sessions`) | +| `user-interaction/` | Abstract human question/answer seam used by UI-backed confirmation tools | `ctx.userInteraction` | +| `tool-ask-user/` | Model-facing `ask_user_question` tool over `ctx.userInteraction` | (registers on `ctx.tools`) | | `stdio-agent/` | Terminal stdio chat APP: the agent-core spine + console logger + readline UI + a pre-created `main` agent, with a `bin` | (composition + `bin`) | | `acp-agent/` | ACP server APP: the agent-core spine + JSONL persistence + the `acp` bridge (no stdout logger), with a `bin` | (composition + `bin`) | | `app-boot/` | Shared boot glue for the two app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) | A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The readline UI is the unstructured analogue of the `acp` bridge and lives INSIDE the stdio app (the `stdio-chat` module of [`stdio-agent/`](stdio-agent/README.md)): it is scaffolding for that one front door, not an independently swappable integration, so it carries no package boundary of its own. +`user-interaction` and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. The seam remains provider-neutral (`ctx.userInteraction`), while the tool is the model-facing consumer and the app/bridge packages provide concrete providers. + `stdio-agent` and `acp-agent` are the two **app packages**: each composes the [`core/agent-core`](../core/agent-core/README.md) spine with its coupled front-door cluster (and owns the boot `bin`), so a leaf `cordis.yml` is the swappable backends plus one app entry plus any optional product tools. They live in `ui/` because each IS a user-facing front door; the stdout-purity coupling (logger vs. no logger) becomes a property of the artifact rather than a leaf convention. diff --git a/packages/ui/acp-agent/README.md b/packages/ui/acp-agent/README.md index 36e794f92c..ee3a995eaa 100644 --- a/packages/ui/acp-agent/README.md +++ b/packages/ui/acp-agent/README.md @@ -11,8 +11,10 @@ stdout is the ACP JSON-RPC channel, so the cluster is defined as much by what it | Plugin | Why | |---|---| | `@deepseek-ai/dsh-agent-core` | the spine, pre-creating **no** agents (ACP `session/new` creates them on demand) | +| `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by clients that can complete ACP elicitation requests | | `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log (the bridge advertises `loadSession`) | -| `@deepseek-ai/dsh-acp` | the bridge that owns stdout for JSON-RPC | +| `@deepseek-ai/dsh-acp` | the bridge that owns stdout for JSON-RPC and provides ACP-backed user answers when a leaf explicitly exposes a user-question tool | +| ~~`@deepseek-ai/dsh-tool-ask-user`~~ | **omitted by default** — ACP elicitation support is still client-dependent, so leaves must opt in deliberately | | ~~console logger~~ | **omitted** — it writes to stdout and would corrupt the protocol frames ([the stdout-purity footgun](../acp/README.md)) | | ~~`hmr`~~ | **omitted** — the editor owns the subprocess | diff --git a/packages/ui/acp-agent/package.json b/packages/ui/acp-agent/package.json index e8b4f3723b..51eb0ea3b1 100644 --- a/packages/ui/acp-agent/package.json +++ b/packages/ui/acp-agent/package.json @@ -36,6 +36,7 @@ "@deepseek-ai/dsh-acp": "^0.0.1", "@deepseek-ai/dsh-agent-core": "^0.0.1", "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1", + "@deepseek-ai/dsh-user-interaction": "^0.0.1", "cordis": "^4.0.0-rc.6", "schemastery": "^3.17.0" }, @@ -47,6 +48,7 @@ "@deepseek-ai/dsh-agent-core": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-user-interaction": "workspace:^", "cordis": "^4.0.0-rc.6", "schemastery": "^3.17.0" } diff --git a/packages/ui/acp-agent/src/index.ts b/packages/ui/acp-agent/src/index.ts index 898ec9a509..18490ac668 100644 --- a/packages/ui/acp-agent/src/index.ts +++ b/packages/ui/acp-agent/src/index.ts @@ -35,6 +35,7 @@ import z from 'schemastery' import * as acp from '@deepseek-ai/dsh-acp' import * as agentCore from '@deepseek-ai/dsh-agent-core' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import UserInteractionService from '@deepseek-ai/dsh-user-interaction' export const name = 'acp-agent' @@ -79,6 +80,7 @@ export function apply(ctx: Context, config: Config): void { ...config.persona !== undefined ? { persona: config.persona } : {}, ...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {}, }) + ctx.plugin(UserInteractionService) ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' }) ctx.plugin(acp, { model: config.model }) } diff --git a/packages/ui/acp-agent/tests/acp-agent.spec.ts b/packages/ui/acp-agent/tests/acp-agent.spec.ts index 7e1de1226f..6e2f202859 100644 --- a/packages/ui/acp-agent/tests/acp-agent.spec.ts +++ b/packages/ui/acp-agent/tests/acp-agent.spec.ts @@ -30,6 +30,8 @@ describe('dsh-acp-agent composition', () => { expect(ctx.get('sessions')).toBeDefined() expect(ctx.get('sessionPersistence')).toBeDefined() expect(ctx.get('agentLoop')).toBeDefined() + expect(ctx.get('userInteraction')).toBeDefined() + expect(ctx.get('tools')?.get('ask_user_question')).toBeUndefined() // No pre-created agents — ACP session/new creates them on demand. expect(ctx.get('agents')!.list()).toHaveLength(0) await ctx.fiber.dispose() diff --git a/packages/ui/acp-agent/tsconfig.json b/packages/ui/acp-agent/tsconfig.json index 6cc211087c..13009a2e5c 100644 --- a/packages/ui/acp-agent/tsconfig.json +++ b/packages/ui/acp-agent/tsconfig.json @@ -26,6 +26,12 @@ { "path": "../../core/agent-core" }, + { + "path": "../user-interaction" + }, + { + "path": "../tool-ask-user" + }, { "path": "../../session-persistence/session-persistence-jsonl" } diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index 60363566b7..49abfbff6a 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -8,7 +8,7 @@ It is a **client-driver / UI plugin**, the structured analogue of the readline ` `apply(ctx, config)` — wires an `AgentSideConnection` (from `@agentclientprotocol/sdk`) to `process.stdin`/`process.stdout` and implements the ACP `Agent` method surface. -`inject: ['agents', 'sessions', 'sessionPersistence', 'tools']` — programs against the interface packages only (never `dsh-agent-loop`). `sessionPersistence` is required because `initialize` advertises `loadSession: true`; `tools` lets a tool own how its calls render (`presentCall`/`presentResult`) — the bridge looks the definition up by name and falls back to a generic presentation when a tool declares none (see Tool-call presentation). +`inject: ['agents', 'sessions', 'sessionPersistence', 'tools', 'userInteraction']` — programs against the interface packages only (never `dsh-agent-loop`). `sessionPersistence` is required because `initialize` advertises `loadSession: true`; `tools` lets a tool own how its calls render (`presentCall`/`presentResult`) — the bridge looks the definition up by name and falls back to a generic presentation when a tool declares none (see Tool-call presentation). `userInteraction` lets agent-owned `ask_user_question` calls become ACP form elicitations routed to the owning session. ### Config @@ -30,6 +30,7 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name: | `session/prompt` | `agent.send()` | supports ACP `text` and `resource_link` blocks; rejects image/audio/embedded resource and empty prompts; one in-flight prompt PER session (independent); settles on the OWNING turn's end (a turn that ends in `error` rejects the RPC) | | `session/cancel` | `agent.cancel()` | the queue-aware cancel: aborts a running step, clears queued + steering work, and drops a turn about to start, then settles the prompt `cancelled` — for ONLY that session (a cancel never touches another session's stream or prompt) | | `session/update` | `session/event` | `agent_message_chunk` (text-delta), `agent_thought_chunk` (reasoning-delta), `user_message_chunk` (load replay), `tool_call`/`tool_call_update` (the render intent — a `card`-tagged `ToolCallView`/`ToolResultView` — owned by the TOOL via `presentCall`/`presentResult`, which the bridge switches on to build the wire shape — see Tool-call presentation) | +| `elicitation/create` | `ctx.userInteraction.ask()` | maps `ask_user_question` questions to ACP form elicitations; option descriptions are shown in enum titles, `multi_select` uses ACP array enums, optionless requests use a required `custom` field, and a non-empty custom answer overrides any selected choice | ## Multi-session diff --git a/packages/ui/acp/acp-feature-support.md b/packages/ui/acp/acp-feature-support.md index d37c9b1eaf..6162fae137 100644 --- a/packages/ui/acp/acp-feature-support.md +++ b/packages/ui/acp/acp-feature-support.md @@ -47,7 +47,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs | `terminal/wait_for_exit` | S | ❌ | ❌ | ❌ | As above. | | `terminal/kill` | S | ❌ | ❌ | ❌ | As above. | | `terminal/release` | S | ❌ | ❌ | ❌ | As above. | -| `elicitation/create` · `elicitation/complete` | U | ❌ | ✅ | ⚠️ | Structured user-input forms. Claude calls the `unstable_*` elicitation methods (to surface MCP server elicitations); Codex does NOT — its `CodexElicitationHandler` maps elicitations onto `session/request_permission` instead. | +| `elicitation/create` · `elicitation/complete` | U | ⚠️ | ✅ | ⚠️ | The bridge drives `unstable_createElicitation` for `ask_user_question` form prompts (session-scoped, no URL-mode flow yet). Claude calls the `unstable_*` elicitation methods for MCP server elicitations; Codex maps elicitations onto `session/request_permission`. | ## 3. Capabilities @@ -140,7 +140,7 @@ The bridge rejects unsupported prompt blocks rather than silently dropping them Ranked by how commonly the reference adapters ship them and how much UX they unlock: -1. **Permission gate** — `session/request_permission` + permission options. Tracked `TODO(rfc010-permission-gate)`; the reverse map is already wired. Foundational, and a prerequisite for modes. +1. **Permission gate** — `session/request_permission` + permission options. Tracked `TODO(rfc010-permission-gate)`; the reverse map is already wired and shared with `ask_user_question` routing. Foundational, and a prerequisite for modes. 2. **Session lifecycle** — `session/list` + `session/delete` (the persistence layer already lists), then `session/resume` / `session/close`. 3. **Modes / config options / model selection** — coupled to the permission gate. 4. **Agent plan** (`sessionUpdate: 'plan'`) — surface the loop's plan as structured entries. diff --git a/packages/ui/acp/package.json b/packages/ui/acp/package.json index cdc952558c..4ebc8485ce 100644 --- a/packages/ui/acp/package.json +++ b/packages/ui/acp/package.json @@ -32,6 +32,7 @@ "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", + "@deepseek-ai/dsh-user-interaction": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "devDependencies": { @@ -49,6 +50,8 @@ "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-tool-ask-user": "workspace:^", + "@deepseek-ai/dsh-user-interaction": "workspace:^", "cordis": "^4.0.0-rc.6" } } diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index b4fa277311..928ab7da77 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -47,6 +47,9 @@ import { type AuthenticateRequest, type CancelNotification, type ContentBlock as AcpContentBlock, + type CreateElicitationRequest, + type ElicitationContentValue, + type EnumOption, type InitializeRequest, type InitializeResponse, type LoadSessionRequest, @@ -71,6 +74,14 @@ import type { ToolCallView, ToolRegistry, ToolResultView, TerminalResultView } f // Side-effect type import: declaration-merges `ctx.sessionPersistence` onto // Context (the bridge injects it and reads `list()` for load cwd validation). import type {} from '@deepseek-ai/dsh-session-persistence' +import { + UserInteractionError, + type AskUserQuestionAnswer, + type AskUserQuestionAnswerItem, + type AskUserQuestionItem, + type AskUserQuestionOption, + type AskUserQuestionRequest, +} from '@deepseek-ai/dsh-user-interaction' import { acpPromptToText, harnessBlockToAcpContent, @@ -84,7 +95,7 @@ export const name = 'acp' // because `initialize` advertises `loadSession: true`. `tools` lets a tool own // how its calls render (`presentCall`/`presentResult`); the bridge looks up the // definition by name and falls back to a generic presentation when absent. -export const inject = ['agents', 'sessions', 'sessionPersistence', 'tools'] +export const inject = ['agents', 'sessions', 'sessionPersistence', 'tools', 'userInteraction'] /** * Build an ACP "invalid params" error whose human detail rides in the message. @@ -111,6 +122,116 @@ function sameWorkspaceCwd(left: string, right: string): boolean { return resolvePath(left) === resolvePath(right) } +function optionDescription(option: AskUserQuestionOption): string { + return option.description === undefined + ? option.label + : `${option.label}: ${option.description}` +} + +function requireStringContent( + content: Record | null | undefined, + key: string, +): string | undefined { + const value = content?.[key] + return typeof value === 'string' && value.trim().length > 0 ? value : undefined +} + +function askAbortError(): UserInteractionError { + return new UserInteractionError('ask_user_question was aborted before the user answered', 'ASK_ABORTED') +} + +function withAbort(promise: Promise, signal: AbortSignal | undefined): Promise { + if (signal === undefined) return promise + if (signal.aborted) return Promise.reject(askAbortError()) + return new Promise((resolve, reject) => { + const onAbort = (): void => { + signal.removeEventListener('abort', onAbort) + reject(askAbortError()) + } + signal.addEventListener('abort', onAbort, { once: true }) + promise.then( + (value) => { + signal.removeEventListener('abort', onAbort) + resolve(value) + }, + (error: unknown) => { + signal.removeEventListener('abort', onAbort) + reject(new Error(String(error), { cause: error })) + }, + ) + }) +} + +function elicitationForQuestion( + sessionId: SessionId, + question: AskUserQuestionItem, + options: AskUserQuestionOption[], +): CreateElicitationRequest { + const title = question.header ?? 'Question' + if (options.length === 0) { + return { + sessionId, + mode: 'form', + message: question.question, + requestedSchema: { + type: 'object', + title, + properties: { + custom: { type: 'string', title: question.question }, + }, + required: ['custom'], + }, + } + } + + const choiceOptions: EnumOption[] = options.map(option => ({ + const: option.label, + title: optionDescription(option), + })) + const choice = question.multiSelect === true + ? { + type: 'array' as const, + title: question.question, + description: 'Choose one or more options, or fill a custom answer below.', + items: { + anyOf: choiceOptions, + }, + } + : { + type: 'string' as const, + title: question.question, + description: 'Choose one option, or fill a custom answer below.', + oneOf: choiceOptions, + } + return { + sessionId, + mode: 'form', + message: question.question, + requestedSchema: { + type: 'object', + title, + properties: { + choice, + custom: { + type: 'string', + title: 'Custom answer', + description: 'Optional free-form answer. Leave empty to use the selected option.', + }, + }, + required: [], + }, + } +} + +function stringArrayContent( + content: Record | null | undefined, + key: string, +): string[] { + const value = content?.[key] + if (Array.isArray(value)) return value.filter((item): item is string => typeof item === 'string' && item.length > 0) + return typeof value === 'string' && value.length > 0 ? [value] : [] +} + /** Plugin config: the agent template ACP sessions are created from. */ export interface AcpConfig { /** Model name for created agents (must have a registered adapter). */ @@ -211,6 +332,7 @@ export function apply(ctx: Context, config: AcpConfig): void { const sessionPersistence = ctx.sessionPersistence const logger = ctx.logger const tools = ctx.tools + const userInteraction = ctx.userInteraction // A new ToolPresenter per session (and a throwaway per load replay), each given // this warn sink so a throwing tool presenter is logged, not propagated. const makePresenter = (): ToolPresenter => new ToolPresenter(tools, (message) => { logger.warn(message) }) @@ -241,6 +363,42 @@ export function apply(ctx: Context, config: AcpConfig): void { // `notify` never observes it unset — no undefined guard needed. let conn: AgentSideConnection + userInteraction.registerProvider({ + async ask(request: AskUserQuestionRequest): Promise { + if (request.agent === undefined) { + throw new UserInteractionError('ACP user questions must come from an agent-owned request', 'NO_AGENT') + } + const sessionId = bySession.get(request.agent) + if (sessionId === undefined) { + throw new UserInteractionError('ACP user question has no matching session', 'NO_SESSION') + } + const answers: AskUserQuestionAnswerItem[] = [] + for (const question of request.questions) { + const options = question.options ?? [] + const response = await withAbort(conn.unstable_createElicitation( + elicitationForQuestion(sessionId, question, options), + ), request.signal).catch((error: unknown) => { + if (error instanceof UserInteractionError) throw error + throw new UserInteractionError('ACP elicitation request failed', 'ASK_FAILED', { cause: error }) + }) + if (response.action !== 'accept') { + throw new UserInteractionError('ask_user_question was cancelled by the user', 'ASK_CANCELLED') + } + const custom = requireStringContent(response.content, 'custom') + const selected = stringArrayContent(response.content, 'choice') + if (custom === undefined && selected.length === 0) { + throw new UserInteractionError('ask_user_question returned no answer', 'NO_ANSWER') + } + answers.push({ + id: question.id, + selected: custom === undefined ? selected : [], + ...custom !== undefined ? { custom } : {}, + }) + } + return { answers } + }, + }) + /** * Reject any RPC after the bridge has torn down. The `AgentSideConnection` * receive loop can outlive the plugin fiber — under an ACP-only HMR reload the diff --git a/packages/ui/acp/tests/bridge.spec.ts b/packages/ui/acp/tests/bridge.spec.ts index 1e7e9ae511..be05a09644 100644 --- a/packages/ui/acp/tests/bridge.spec.ts +++ b/packages/ui/acp/tests/bridge.spec.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { AgentId } from '@deepseek-ai/dsh-agent' -import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts' +import { makeBridgeHarness, textResponse, toolCallResponse, type BridgeHarness } from './harness.ts' /** * End-to-end bridge specs over an in-memory transport: a real @@ -53,6 +53,209 @@ describe('acp bridge', () => { expect(text).toBe('hello there') }) + it('routes ask_user_question through ACP form elicitation and continues with the selected option', async () => { + harness = await makeBridgeHarness({ + storageDir, + withAskUser: true, + script: [ + toolCallResponse('ask-1', 'ask_user_question', { + questions: [{ + id: 'language', + header: 'Project config', + question: 'Which language should I use?', + options: [ + { label: 'TypeScript', description: 'Good for UI apps' }, + { label: 'Python', description: 'Good for scripts' }, + ], + }], + }), + textResponse('Python it is.'), + ], + }) + harness.onElicitation = () => ({ action: 'accept', content: { choice: 'Python' } }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + const result = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'ask me' }] }) + + expect(result.stopReason).toBe('end_turn') + expect(harness.elicitationRequests).toHaveLength(1) + expect(harness.elicitationRequests[0]).toMatchObject({ + sessionId, + mode: 'form', + message: 'Which language should I use?', + requestedSchema: { + title: 'Project config', + properties: { + choice: { + oneOf: [ + { const: 'TypeScript', title: 'TypeScript: Good for UI apps' }, + { const: 'Python', title: 'Python: Good for scripts' }, + ], + }, + custom: { type: 'string' }, + }, + required: [], + }, + }) + const toolResult = harness.ctx.agents.get(AgentId(sessionId))!.session.events.find(event => event.type === 'tool/result') + const toolResultBlock = toolResult?.type === 'tool/result' ? toolResult.data.content[0] : undefined + const toolResultText = toolResultBlock?.type === 'text' ? toolResultBlock.text : undefined + expect(toolResultText).toBe('{"answers":[{"id":"language","selected":["Python"]}]}') + }) + + it('routes optionless ask_user_question through an ACP free-form answer field', async () => { + harness = await makeBridgeHarness({ + storageDir, + withAskUser: true, + script: [ + toolCallResponse('ask-1', 'ask_user_question', { + questions: [{ id: 'name', question: 'What should I name it?' }], + }), + textResponse('Name recorded.'), + ], + }) + harness.onElicitation = () => ({ action: 'accept', content: { custom: 'apollo' } }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'ask me' }] }) + + expect(harness.elicitationRequests[0]).toMatchObject({ + requestedSchema: { + properties: { custom: { type: 'string', title: 'What should I name it?' } }, + required: ['custom'], + }, + }) + const toolResult = harness.ctx.agents.get(AgentId(sessionId))!.session.events.find(event => event.type === 'tool/result') + expect(JSON.stringify(toolResult)).toContain('apollo') + }) + + it('supports ACP custom answers alongside choices', async () => { + harness = await makeBridgeHarness({ storageDir, withAskUser: true }) + harness.onElicitation = () => ({ action: 'accept', content: { custom: 'Use Zig' } }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const agent = harness.ctx.agents.get(AgentId(sessionId))! + + const result = await harness.ctx.userInteraction.ask({ + agent, + questions: [{ + id: 'language', + question: 'Which language?', + options: [{ label: 'TypeScript' }], + }], + }) + + expect(result).toEqual({ answers: [{ id: 'language', selected: [], custom: 'Use Zig' }] }) + expect(harness.elicitationRequests[0]).toMatchObject({ + requestedSchema: { + properties: { + choice: { + description: 'Choose one option, or fill a custom answer below.', + oneOf: [{ const: 'TypeScript', title: 'TypeScript' }], + }, + custom: { type: 'string' }, + }, + required: [], + }, + }) + }) + + it('treats ACP custom answers as overriding selected choices', async () => { + harness = await makeBridgeHarness({ storageDir, withAskUser: true }) + harness.onElicitation = () => ({ action: 'accept', content: { choice: 'TypeScript', custom: 'Use Zig' } }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const agent = harness.ctx.agents.get(AgentId(sessionId))! + + await expect(harness.ctx.userInteraction.ask({ + agent, + questions: [{ + id: 'language', + question: 'Which language?', + options: [{ label: 'TypeScript' }], + }], + })).resolves.toEqual({ answers: [{ id: 'language', selected: [], custom: 'Use Zig' }] }) + }) + + it('supports ACP multi-select answers', async () => { + harness = await makeBridgeHarness({ storageDir, withAskUser: true }) + harness.onElicitation = () => ({ action: 'accept', content: { choice: ['Tests', 'Docs'] } }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const agent = harness.ctx.agents.get(AgentId(sessionId))! + + await expect(harness.ctx.userInteraction.ask({ + agent, + questions: [{ + id: 'targets', + question: 'Pick', + options: [{ label: 'Tests' }, { label: 'Docs' }], + multiSelect: true, + }], + })).resolves.toEqual({ answers: [{ id: 'targets', selected: ['Tests', 'Docs'] }] }) + }) + + it('reports ACP ask-user routing and answer failures as structured errors', async () => { + harness = await makeBridgeHarness({ storageDir, withAskUser: true }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const agent = harness.ctx.agents.get(AgentId(sessionId))! + + await expect(harness.ctx.userInteraction.ask({ questions: [{ id: 'x', question: 'No agent?' }] })) + .rejects.toMatchObject({ name: 'UserInteractionError', code: 'NO_AGENT' }) + await expect(harness.ctx.userInteraction.ask({ agent: { id: 'other' } as typeof agent, questions: [{ id: 'x', question: 'No session?' }] })) + .rejects.toMatchObject({ code: 'NO_SESSION' }) + + harness.onElicitation = () => ({ action: 'cancel' }) + await expect(harness.ctx.userInteraction.ask({ agent, questions: [{ id: 'x', question: 'Cancel?' }] })) + .rejects.toMatchObject({ code: 'ASK_CANCELLED' }) + + harness.onElicitation = () => ({ action: 'accept', content: {} }) + await expect(harness.ctx.userInteraction.ask({ agent, questions: [{ id: 'x', question: 'Empty?' }] })) + .rejects.toMatchObject({ code: 'NO_ANSWER' }) + + harness.onElicitation = () => { throw new Error('client boom') } + await expect(harness.ctx.userInteraction.ask({ agent, questions: [{ id: 'x', question: 'Client fails?' }], signal: new AbortController().signal })) + .rejects.toMatchObject({ code: 'ASK_FAILED' }) + }) + + it('aborts ACP ask-user requests before and while waiting for elicitation', async () => { + harness = await makeBridgeHarness({ storageDir, withAskUser: true }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const agent = harness.ctx.agents.get(AgentId(sessionId))! + + const alreadyAborted = new AbortController() + alreadyAborted.abort() + await expect(harness.ctx.userInteraction.ask({ agent, questions: [{ id: 'x', question: 'Already?' }], signal: alreadyAborted.signal })) + .rejects.toMatchObject({ code: 'ASK_ABORTED' }) + + let abortedReads = 0 + const racingAbort = { + get aborted() { return abortedReads++ > 0 }, + addEventListener() {}, + removeEventListener() {}, + dispatchEvent() { return false }, + onabort: null, + reason: undefined, + throwIfAborted() {}, + } as AbortSignal + await expect(harness.ctx.userInteraction.ask({ agent, questions: [{ id: 'x', question: 'Raced?' }], signal: racingAbort })) + .rejects.toMatchObject({ code: 'ASK_ABORTED' }) + + let release: ((value: { action: 'accept'; content: { custom: string } }) => void) | undefined + harness.onElicitation = () => new Promise((resolve) => { release = resolve }) + const pendingAbort = new AbortController() + const ask = harness.ctx.userInteraction.ask({ agent, questions: [{ id: 'x', question: 'Pending?' }], signal: pendingAbort.signal }) + await new Promise(resolve => setImmediate(resolve)) + pendingAbort.abort() + + await expect(ask).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + release?.({ action: 'accept', content: { custom: 'too late' } }) + }) + it('allows multiple concurrent sessions, each with a distinct id', async () => { harness = await makeBridgeHarness({ storageDir, script: [] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) diff --git a/packages/ui/acp/tests/harness.ts b/packages/ui/acp/tests/harness.ts index 39d77fe624..a24c7aa145 100644 --- a/packages/ui/acp/tests/harness.ts +++ b/packages/ui/acp/tests/harness.ts @@ -29,11 +29,15 @@ import { ndJsonStream, type Agent as AcpAgent, type Client, + type CreateElicitationRequest, + type CreateElicitationResponse, type RequestPermissionRequest, type RequestPermissionResponse, type SessionNotification, type Stream, } from '@agentclientprotocol/sdk' +import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user' import * as AcpPlugin from '../src/index.ts' import { type AcpConfig } from '../src/index.ts' @@ -121,6 +125,10 @@ export interface BridgeHarness { permissionRequests: RequestPermissionRequest[] /** Decide each permission request's outcome (default: cancelled). */ onPermission: (req: RequestPermissionRequest) => RequestPermissionResponse + /** Elicitation requests the bridge issued for ask_user_question. */ + elicitationRequests: CreateElicitationRequest[] + /** Decide each elicitation response (default: cancel). */ + onElicitation: (req: CreateElicitationRequest) => CreateElicitationResponse | Promise /** If set, the client's sessionUpdate throws this (tests notify error path). */ onSessionUpdateError: (() => void) | undefined /** @@ -164,6 +172,8 @@ export async function makeBridgeHarness(options: { * implementation over a mock in tests"). */ withBash?: boolean + /** Plug the REAL `ask_user_question` tool and ACP user-interaction provider. */ + withAskUser?: boolean /** * Plug the REAL `dsh-tool-todo` tool so a test can drive `todo_write` through * the bridge and assert the resulting `plan` sessionUpdate — the shipping @@ -190,6 +200,10 @@ export async function makeBridgeHarness(options: { await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SessionPersistenceJsonl, { root: options.storageDir }) + await ctx.plugin(UserInteractionService) + if (options.withAskUser) { + await ctx.plugin(ToolAskUser) + } if (options.withBash) { await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) await ctx.plugin(ToolBash) @@ -226,6 +240,7 @@ export async function makeBridgeHarness(options: { const updates: CapturedUpdate[] = [] const sessionUpdates: { sessionId: string; update: CapturedUpdate }[] = [] const permissionRequests: RequestPermissionRequest[] = [] + const elicitationRequests: CreateElicitationRequest[] = [] const harness: BridgeHarness = { ctx, adapter, @@ -233,6 +248,8 @@ export async function makeBridgeHarness(options: { sessionUpdates, permissionRequests, onPermission: () => ({ outcome: { outcome: 'cancelled' } }), + elicitationRequests, + onElicitation: () => ({ action: 'cancel' }), onSessionUpdateError: undefined, client: undefined as unknown as ClientSideConnection, acpFiber: undefined as unknown as BridgeHarness['acpFiber'], @@ -258,6 +275,10 @@ export async function makeBridgeHarness(options: { permissionRequests.push(params) return Promise.resolve(harness.onPermission(params)) }, + unstable_createElicitation(params: CreateElicitationRequest): Promise { + elicitationRequests.push(params) + return Promise.resolve(harness.onElicitation(params)) + }, }) // Wire the bridge (agent side) and the client (test side). The test config diff --git a/packages/ui/acp/tsconfig.json b/packages/ui/acp/tsconfig.json index 5989363d7f..9c2358c455 100644 --- a/packages/ui/acp/tsconfig.json +++ b/packages/ui/acp/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../../core/tools" }, + { + "path": "../user-interaction" + }, { "path": "../../session-persistence/session-persistence" } diff --git a/packages/ui/stdio-agent/README.md b/packages/ui/stdio-agent/README.md index 5fb0bbe78c..a299e2a94a 100644 --- a/packages/ui/stdio-agent/README.md +++ b/packages/ui/stdio-agent/README.md @@ -13,6 +13,8 @@ A terminal chat always wants the same cluster, so the package owns it rather tha | `@cordisjs/plugin-logger-console` | the console logger — stdout is just the terminal here, so logging to it is correct (the ACP app must NOT have this) | | `@deepseek-ai/dsh-agent-core` | the spine, pre-creating a `main` agent from this app's `model` and carrying its `persona` | | `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log under `persistenceRoot` | +| `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by confirmation tools | +| `@deepseek-ai/dsh-tool-ask-user` | the model-facing `ask_user_question` tool | | `stdio-chat` (in-package module) | the readline UI, bound to the `main` agent | `@cordisjs/plugin-hmr` (the dev/demo edit-reload loop) is deliberately a **leaf** entry, NOT baked in here: it is a Loader-only, subprocess-only dev plugin — its constructor throws without `node --expose-internals` + a live `loader`, and the in-process test tier cannot even import it (so a package whose `apply` statically pulled it in could never carry the per-file coverage gate). Unlike the console logger, a stray `hmr` is not a stdout-purity footgun, so leaving it at the leaf costs no safety. The `demo:echo` / `demo:repl` leaves load it and pass `--expose-internals`. diff --git a/packages/ui/stdio-agent/package.json b/packages/ui/stdio-agent/package.json index b8cf84cac4..39630862d8 100644 --- a/packages/ui/stdio-agent/package.json +++ b/packages/ui/stdio-agent/package.json @@ -39,6 +39,8 @@ "@deepseek-ai/dsh-agent-core": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1", + "@deepseek-ai/dsh-tool-ask-user": "^0.0.1", + "@deepseek-ai/dsh-user-interaction": "^0.0.1", "cordis": "^4.0.0-rc.6", "schemastery": "^3.17.0" }, @@ -53,6 +55,8 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-tool-ask-user": "workspace:^", + "@deepseek-ai/dsh-user-interaction": "workspace:^", "cordis": "^4.0.0-rc.6", "schemastery": "^3.17.0" } diff --git a/packages/ui/stdio-agent/src/index.ts b/packages/ui/stdio-agent/src/index.ts index 7d36db373e..58408486ea 100644 --- a/packages/ui/stdio-agent/src/index.ts +++ b/packages/ui/stdio-agent/src/index.ts @@ -45,6 +45,8 @@ import { AgentId } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import * as agentCore from '@deepseek-ai/dsh-agent-core' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user' import * as uiStdio from './stdio-chat.ts' export const name = 'stdio-agent' @@ -107,5 +109,7 @@ export function apply(ctx: Context, config: Config): void { }], }) ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' }) + ctx.plugin(UserInteractionService) + ctx.plugin(toolAskUser) ctx.plugin(uiStdio, { welcome: config.welcome ?? 'ready.', agent: 'main' }) } diff --git a/packages/ui/stdio-agent/src/stdio-chat.ts b/packages/ui/stdio-agent/src/stdio-chat.ts index 78b0a5ae5b..4c37f40d37 100644 --- a/packages/ui/stdio-agent/src/stdio-chat.ts +++ b/packages/ui/stdio-agent/src/stdio-chat.ts @@ -20,9 +20,17 @@ import type { Readable, Writable } from 'node:stream' import type { Context } from 'cordis' import z from 'schemastery' import { AgentId } from '@deepseek-ai/dsh-agent' +import { + UserInteractionError, + type AskUserQuestionAnswer, + type AskUserQuestionAnswerItem, + type AskUserQuestionItem, + type AskUserQuestionOption, + type AskUserQuestionRequest, +} from '@deepseek-ai/dsh-user-interaction' export const name = 'ui-stdio' -export const inject = ['agents'] +export const inject = ['agents', 'userInteraction'] /** Serializable plugin configuration (cordis-native, schemastery). */ export interface Config { @@ -57,6 +65,20 @@ function isTTYPair(input: Readable, output: Writable): boolean { return Boolean((input as { isTTY?: boolean }).isTTY && (output as { isTTY?: boolean }).isTTY) } +interface PendingQuestion { + request: AskUserQuestionRequest + questionIndex: number + answers: AskUserQuestionAnswerItem[] + resolve(answer: AskUserQuestionAnswer): void + reject(error: unknown): void + onAbort: () => void +} + +type OptionSelection = + | { kind: 'selected'; options: AskUserQuestionOption[] } + | { kind: 'custom' } + | { kind: 'invalid' } + /** * The plugin body, parameterized over its I/O runtime. `apply` is the thin * production wrapper that binds the real `process` streams; tests call this @@ -154,6 +176,8 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt let submittedWork = false let sawRunning = false let exitTimer: ReturnType | undefined + let activeQuestion: PendingQuestion | undefined + const questionQueue: PendingQuestion[] = [] const maybeExit = (): void => { if (disposed || !stdinClosed) return @@ -180,7 +204,152 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt if (status === 'idle') maybeExit() }) + const activeQuestionItem = (pending: PendingQuestion): AskUserQuestionItem => + pending.request.questions[pending.questionIndex] as AskUserQuestionItem + + const renderQuestion = (pending: PendingQuestion): void => { + const question = activeQuestionItem(pending) + const options = question.options ?? [] + output.write('\n') + output.write(question.header ? `[${question.header}] ${question.question}\n` : `${question.question}\n`) + options.forEach((option, index) => { + output.write(` ${index + 1}. ${option.label}\n`) + if (option.description) output.write(` ${option.description}\n`) + }) + output.write('> ') + } + + const removeAbortListener = (pending: PendingQuestion): void => { + pending.request.signal?.removeEventListener('abort', pending.onAbort) + } + + const startNextQuestion = (): void => { + if (activeQuestion !== undefined) return + const pending = questionQueue.shift() + if (pending === undefined) return + // The queue never contains an aborted pending ask: the seam rejects an + // already-aborted request synchronously, and queued asks attach their + // abort listener before enqueueing. + activeQuestion = pending + renderQuestion(pending) + } + + const disposeQuestion = (pending: PendingQuestion): void => { + removeAbortListener(pending) + pending.reject(new UserInteractionError('ask_user_question was interrupted before the user answered', 'ASK_ABORTED')) + } + + const disposePendingQuestions = (): void => { + if (activeQuestion !== undefined) { + disposeQuestion(activeQuestion) + activeQuestion = undefined + } + for (const pending of questionQueue.splice(0)) { + disposeQuestion(pending) + } + } + + const finishQuestion = (pending: PendingQuestion): void => { + activeQuestion = undefined + removeAbortListener(pending) + pending.resolve({ answers: pending.answers }) + output.write('\n') + startNextQuestion() + } + + const answerCurrentQuestion = (pending: PendingQuestion, answer: AskUserQuestionAnswerItem): void => { + pending.answers.push(answer) + pending.questionIndex += 1 + if (pending.questionIndex >= pending.request.questions.length) { + finishQuestion(pending) + return + } + renderQuestion(pending) + } + + const selectedOptions = (text: string, options: AskUserQuestionOption[], multiSelect: boolean): OptionSelection => { + if (text === '') return { kind: 'invalid' } + if (!multiSelect) { + if (!/^\d+$/.test(text)) return { kind: 'custom' } + const selected = options[Number(text) - 1] + return selected === undefined ? { kind: 'invalid' } : { kind: 'selected', options: [selected] } + } + const indices = text.split(/[,\s]+/).filter(Boolean) + if (indices.length === 0) return { kind: 'invalid' } + if (indices.some(part => !/^\d+$/.test(part))) return { kind: 'custom' } + const uniqueIndices = [...new Set(indices)] + const selected = uniqueIndices.map(part => options[Number(part) - 1]) + return selected.some(option => option === undefined) + ? { kind: 'invalid' } + : { kind: 'selected', options: selected as AskUserQuestionOption[] } + } + + const answerQuestion = (line: string): void => { + const pending = activeQuestion as PendingQuestion + const question = activeQuestionItem(pending) + + const text = line.trim() + const options = question.options ?? [] + const selection = options.length > 0 + ? selectedOptions(text, options, question.multiSelect ?? false) + : { kind: text === '' ? 'invalid' : 'custom' } as OptionSelection + if (selection.kind === 'selected') { + answerCurrentQuestion(pending, { id: question.id, selected: selection.options.map(option => option.label) }) + return + } + + if (selection.kind === 'custom' && text !== '') { + answerCurrentQuestion(pending, { id: question.id, selected: [], custom: text }) + return + } + + output.write(options.length > 0 + ? 'Please enter one of the option numbers' + + (question.multiSelect ? ' (comma or space separated)' : '') + + ' or a custom answer' + + '.\n> ' + : 'Please enter an answer.\n> ') + } + + const disposeUserInteractionProvider = ctx.userInteraction.registerProvider({ + ask(request) { + if (disposed || stdinClosed) { + return Promise.reject( + new UserInteractionError('ask_user_question cannot be answered because stdin is closed', 'ASK_ABORTED'), + ) + } + return new Promise((resolve, reject) => { + const pending: PendingQuestion = { + request, + questionIndex: 0, + answers: [], + resolve, + reject, + onAbort: () => { + if (activeQuestion === pending) { + activeQuestion = undefined + disposeQuestion(pending) + startNextQuestion() + return + } + // If it is not active, this listener can only fire while the ask + // remains queued; settled asks remove the listener first. + questionQueue.splice(questionQueue.indexOf(pending), 1) + disposeQuestion(pending) + }, + } + request.signal?.addEventListener('abort', pending.onAbort, { once: true }) + questionQueue.push(pending) + startNextQuestion() + }) + }, + }) + reader.on('line', (line) => { + if (activeQuestion !== undefined) { + answerQuestion(line) + return + } const text = line.trim() if (!text) return const agent = ctx.agents.get(agentId) @@ -199,12 +368,15 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt // Fires for BOTH stdin EOF and plugin disposal (reader.close() below); // `disposed` guards teardown so HMR/dispose never exits the process. stdinClosed = true + if (!disposed) disposePendingQuestions() maybeExit() }) output.write(`${welcome}\n> `) return () => { disposed = true if (exitTimer !== undefined) clearTimeout(exitTimer) + disposePendingQuestions() + disposeUserInteractionProvider() disposeStatusListener() reader.close() } diff --git a/packages/ui/stdio-agent/tests/readline.spec.ts b/packages/ui/stdio-agent/tests/readline.spec.ts index ae3c480756..a958c435c1 100644 --- a/packages/ui/stdio-agent/tests/readline.spec.ts +++ b/packages/ui/stdio-agent/tests/readline.spec.ts @@ -19,6 +19,7 @@ function fakeContext(): Context { // The UI seeds its label map from the registry at install; this suite only // exercises readline terminal-mode selection, so an empty roster suffices. agents: { list: vi.fn(() => []) }, + userInteraction: { registerProvider: vi.fn(() => vi.fn()) }, } as unknown as Context } diff --git a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts index 34ba201040..1f5a01a5b6 100644 --- a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts @@ -37,6 +37,8 @@ describe('dsh-stdio-agent app', () => { expect(ctx.get('agents')).toBeDefined() expect(ctx.get('agentLoop')).toBeDefined() expect(ctx.get('sessionPersistence')).toBeDefined() + expect(ctx.get('userInteraction')).toBeDefined() + expect(ctx.get('tools')?.get('ask_user_question')).toBeDefined() // The pre-created `main` agent the UI drives. expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined() await ctx.fiber.dispose() @@ -92,7 +94,7 @@ describe('dsh-stdio-agent app', () => { }) } const assembly = await ctx.get('systemPrompt')!.assemble() - expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha']) + expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'ask_user_question']) await ctx.fiber.dispose() }) diff --git a/packages/ui/stdio-agent/tests/stdio-chat.spec.ts b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts index 090b77ac64..93683d0768 100644 --- a/packages/ui/stdio-agent/tests/stdio-chat.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts @@ -1,10 +1,11 @@ -import { Readable } from 'node:stream' +import { Readable, Writable } from 'node:stream' import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import { createStdioChat, type Config, type StdioRuntime } from '../src/stdio-chat.ts' /** @@ -79,10 +80,11 @@ const CONFIG: Config = { welcome: 'hi there', agent: 'main' } async function setup(config: Config = CONFIG, runtimeOver: Partial = {}) { const ctx = new Context() await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) const { runtime, input, out, exit } = makeRuntime(runtimeOver) const fiber = await ctx.plugin(Object.assign((inner: Context) => { createStdioChat(inner, config, runtime) - }, { inject: ['agents'] })) + }, { inject: ['agents', 'userInteraction'] })) return { ctx, fiber, input, out, exit } } @@ -105,6 +107,30 @@ describe('createStdioChat rendering', () => { // And it drives the default agent id 'main'. }) + it('detects readline terminal mode from both stream TTY flags', async () => { + for (const [inputTTY, outputTTY] of [[true, false], [true, true]] as const) { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + let text = '' + const output = new Writable({ + write(chunk, _encoding, callback) { + text += String(chunk) + callback() + }, + }) as Writable & { isTTY?: boolean } + const { runtime } = makeRuntime({ output }) + ;(runtime.input as Readable & { isTTY?: boolean }).isTTY = inputTTY + output.isTTY = outputTTY + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + createStdioChat(inner, CONFIG, runtime) + }, { inject: ['agents', 'userInteraction'] })) + + expect(text).toContain('hi there') + await fiber.dispose() + } + }) + it('renders text-delta chunks verbatim', async () => { const { ctx, out } = await setup() ctx.emit('session/event', makeSession('main'), chunkEvent({ type: 'text-delta', index: 0, text: 'hello' })) @@ -160,12 +186,13 @@ describe('createStdioChat rendering', () => { // of the raw session id. const ctx = new Context() await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) const agent = makeAgent('main') ctx.agents.register(agent) // registered BEFORE the UI plugin below const { runtime, out } = makeRuntime() await ctx.plugin(Object.assign((inner: Context) => { createStdioChat(inner, CONFIG, runtime) - }, { inject: ['agents'] })) + }, { inject: ['agents', 'userInteraction'] })) ctx.emit('session/event', makeSession('main'), { type: 'turn/start', seq: 1, time: 0, data: { turn: 5, trigger: { kind: 'message' } }, } as SessionEvent) @@ -264,6 +291,347 @@ describe('createStdioChat rendering', () => { }) describe('createStdioChat input', () => { + it('answers a pending user question instead of sending the line to the agent', async () => { + const { ctx, input, out } = await setup() + const agent = makeAgent('main', 'idle') + ctx.agents.register(agent) + + const answer = ctx.userInteraction.ask({ + questions: [{ + id: 'confirm', + header: 'Confirm', + question: 'Proceed with the edit?', + options: [{ label: 'Yes', description: 'Apply the edit now.' }], + }], + }) + await new Promise(r => setImmediate(r)) + input.feed('Use a smaller change') + + await expect(answer).resolves.toEqual({ answers: [{ id: 'confirm', selected: [], custom: 'Use a smaller change' }] }) + expect(agent.sent).toEqual([]) + expect(out.text()).toContain('[Confirm] Proceed with the edit?') + expect(out.text()).toContain('1. Yes') + expect(out.text()).toContain('Apply the edit now.') + }) + + it('answers a pending user question by numeric option selection', async () => { + const { ctx, input } = await setup() + const answer = ctx.userInteraction.ask({ + questions: [{ + id: 'mode', + question: 'Which mode?', + options: [ + { label: 'Safe' }, + { label: 'Fast' }, + ], + }], + }) + await new Promise(r => setImmediate(r)) + input.feed('2') + + await expect(answer).resolves.toEqual({ + answers: [{ id: 'mode', selected: ['Fast'] }], + }) + }) + + it('renders options in input order and selects by displayed number', async () => { + const { ctx, input, out } = await setup() + const answer = ctx.userInteraction.ask({ + questions: [{ + id: 'topic', + question: 'Which topic?', + options: [ + { label: 'Hobbies' }, + { label: 'Work', description: 'Questions about current projects.' }, + { label: 'Casual', description: 'Easy conversation.' }, + ], + }], + }) + await new Promise(r => setImmediate(r)) + + expect(out.text()).toContain([ + 'Which topic?', + ' 1. Hobbies', + ' 2. Work', + ' Questions about current projects.', + ' 3. Casual', + ' Easy conversation.', + ].join('\n')) + input.feed('3') + + await expect(answer).resolves.toEqual({ + answers: [{ id: 'topic', selected: ['Casual'] }], + }) + }) + + it('answers a multi-select question with multiple numeric selections', async () => { + const { ctx, input } = await setup() + const answer = ctx.userInteraction.ask({ + questions: [{ + id: 'targets', + question: 'What should I update?', + options: [{ label: 'Tests' }, { label: 'Docs' }, { label: 'Code' }], + multiSelect: true, + }], + }) + await new Promise(r => setImmediate(r)) + input.feed('1 1, 3') + + await expect(answer).resolves.toEqual({ + answers: [{ id: 'targets', selected: ['Tests', 'Code'] }], + }) + }) + + it('accepts non-numeric multi-select input as a custom answer', async () => { + const { ctx, input } = await setup() + const answer = ctx.userInteraction.ask({ + questions: [{ + id: 'targets', + question: 'What should I update?', + options: [{ label: 'Tests' }, { label: 'Docs' }], + multiSelect: true, + }], + }) + await new Promise(r => setImmediate(r)) + input.feed('the release notes') + + await expect(answer).resolves.toEqual({ + answers: [{ id: 'targets', selected: [], custom: 'the release notes' }], + }) + }) + + it('asks every question in a batch and returns answers by id', async () => { + const { ctx, input, out } = await setup() + const answer = ctx.userInteraction.ask({ + questions: [ + { id: 'language', question: 'Which language?', options: [{ label: 'Python' }, { label: 'TypeScript' }] }, + { id: 'note', question: 'Any note?' }, + ], + }) + await new Promise(r => setImmediate(r)) + input.feed('2') + await new Promise(r => setImmediate(r)) + expect(out.text()).toContain('\nAny note?\n') + input.feed('ship today') + + await expect(answer).resolves.toEqual({ + answers: [ + { id: 'language', selected: ['TypeScript'] }, + { id: 'note', selected: [], custom: 'ship today' }, + ], + }) + }) + + it('re-prompts when option input is invalid', async () => { + const { ctx, input, out } = await setup() + const answer = ctx.userInteraction.ask({ + questions: [{ + id: 'mode', + question: 'Which mode?', + options: [{ label: 'Safe' }], + multiSelect: true, + }], + }) + await new Promise(r => setImmediate(r)) + input.feed('2') + await new Promise(r => setImmediate(r)) + expect(out.text()).toContain('Please enter one of the option numbers (comma or space separated) or a custom answer.') + input.feed('1') + + await expect(answer).resolves.toEqual({ + answers: [{ id: 'mode', selected: ['Safe'] }], + }) + }) + + it('re-prompts when single-select option input is out of range', async () => { + const { ctx, input, out } = await setup() + const answer = ctx.userInteraction.ask({ + questions: [{ + id: 'mode', + question: 'Which mode?', + options: [{ label: 'Safe' }], + }], + }) + await new Promise(r => setImmediate(r)) + input.feed('2') + await new Promise(r => setImmediate(r)) + expect(out.text()).toContain('Please enter one of the option numbers or a custom answer.') + input.feed('1') + + await expect(answer).resolves.toEqual({ + answers: [{ id: 'mode', selected: ['Safe'] }], + }) + }) + + it('re-prompts when multi-select input contains no option numbers', async () => { + const { ctx, input, out } = await setup() + const answer = ctx.userInteraction.ask({ + questions: [{ + id: 'mode', + question: 'Which mode?', + options: [{ label: 'Safe' }], + multiSelect: true, + }], + }) + await new Promise(r => setImmediate(r)) + input.feed(',') + await new Promise(r => setImmediate(r)) + expect(out.text()).toContain('Please enter one of the option numbers (comma or space separated) or a custom answer.') + input.feed('1') + + await expect(answer).resolves.toEqual({ + answers: [{ id: 'mode', selected: ['Safe'] }], + }) + }) + + it('re-prompts when an option question receives an empty answer', async () => { + const { ctx, input, out } = await setup() + const answer = ctx.userInteraction.ask({ + questions: [{ + id: 'mode', + question: 'Which mode?', + options: [{ label: 'Safe' }], + }], + }) + await new Promise(r => setImmediate(r)) + input.feed('') + await new Promise(r => setImmediate(r)) + expect(out.text()).toContain('Please enter one of the option numbers or a custom answer.') + input.feed('1') + + await expect(answer).resolves.toEqual({ + answers: [{ id: 'mode', selected: ['Safe'] }], + }) + }) + + it('re-prompts when a question receives an empty answer', async () => { + const { ctx, input, out } = await setup() + const answer = ctx.userInteraction.ask({ questions: [{ id: 'path', question: 'What should I use?' }] }) + await new Promise(r => setImmediate(r)) + input.feed('') + await new Promise(r => setImmediate(r)) + expect(out.text()).toContain('Please enter an answer.') + input.feed('Use defaults') + + await expect(answer).resolves.toEqual({ answers: [{ id: 'path', selected: [], custom: 'Use defaults' }] }) + }) + + it('rejects an active question when its signal aborts', async () => { + const { ctx } = await setup() + const controller = new AbortController() + const answer = ctx.userInteraction.ask({ questions: [{ id: 'continue', question: 'Continue?' }], signal: controller.signal }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await new Promise(r => setImmediate(r)) + + controller.abort() + + await rejected + }) + + it('continues to the next queued question when the active question aborts', async () => { + const { ctx, input, out } = await setup() + const controller = new AbortController() + const first = ctx.userInteraction.ask({ questions: [{ id: 'first', question: 'First?' }], signal: controller.signal }) + const firstRejected = expect(first).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + const second = ctx.userInteraction.ask({ questions: [{ id: 'second', question: 'Second?' }] }) + await new Promise(r => setImmediate(r)) + + controller.abort() + await firstRejected + await new Promise(r => setImmediate(r)) + expect(out.text()).toContain('\nSecond?\n') + input.feed('second answer') + + await expect(second).resolves.toEqual({ answers: [{ id: 'second', selected: [], custom: 'second answer' }] }) + }) + + it('skips a queued question whose signal aborted before it became active', async () => { + const { ctx, input, out } = await setup() + const controller = new AbortController() + const first = ctx.userInteraction.ask({ questions: [{ id: 'first', question: 'First?' }] }) + const second = ctx.userInteraction.ask({ questions: [{ id: 'second', question: 'Second?' }], signal: controller.signal }) + await new Promise(r => setImmediate(r)) + + controller.abort() + + await expect(Promise.race([ + second.then( + () => 'resolved', + (error: unknown) => (error as { code?: string }).code, + ), + new Promise((resolve) => { setImmediate(() => { resolve('pending') }) }), + ])).resolves.toBe('ASK_ABORTED') + expect(out.text()).not.toContain('\nSecond?\n') + input.feed('first answer') + await expect(first).resolves.toEqual({ answers: [{ id: 'first', selected: [], custom: 'first answer' }] }) + }) + + it('removes an aborted queued question without promoting later queued work early', async () => { + const { ctx, input, out } = await setup() + const controller = new AbortController() + const first = ctx.userInteraction.ask({ questions: [{ id: 'first', question: 'First?' }] }) + const second = ctx.userInteraction.ask({ questions: [{ id: 'second', question: 'Second?' }], signal: controller.signal }) + const third = ctx.userInteraction.ask({ questions: [{ id: 'third', question: 'Third?' }] }) + await new Promise(r => setImmediate(r)) + + controller.abort() + + await expect(second).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + expect(out.text()).toContain('\nFirst?\n') + expect(out.text()).not.toContain('\nSecond?\n') + expect(out.text()).not.toContain('\nThird?\n') + input.feed('first answer') + await new Promise(r => setImmediate(r)) + + expect(out.text()).toContain('\nThird?\n') + input.feed('third answer') + + await expect(first).resolves.toEqual({ answers: [{ id: 'first', selected: [], custom: 'first answer' }] }) + await expect(third).resolves.toEqual({ answers: [{ id: 'third', selected: [], custom: 'third answer' }] }) + }) + + it('rejects active and queued questions when the UI is disposed', async () => { + const { ctx, fiber } = await setup() + const active = ctx.userInteraction.ask({ questions: [{ id: 'active', question: 'Active?' }] }) + const queued = ctx.userInteraction.ask({ questions: [{ id: 'queued', question: 'Queued?' }] }) + const activeRejected = expect(active).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + const queuedRejected = expect(queued).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await new Promise(r => setImmediate(r)) + + await fiber.dispose() + + await activeRejected + await queuedRejected + }) + + it('rejects active and queued questions when stdin closes before the user answers', async () => { + const { ctx, input, exit } = await setup() + const active = ctx.userInteraction.ask({ questions: [{ id: 'active', question: 'Active?' }] }) + const queued = ctx.userInteraction.ask({ questions: [{ id: 'queued', question: 'Queued?' }] }) + const activeRejected = expect(active).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + const queuedRejected = expect(queued).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await new Promise(r => setImmediate(r)) + + input.finish() + await new Promise(r => setImmediate(r)) + + await activeRejected + await queuedRejected + expect(exit).not.toHaveBeenCalled() + }) + + it('rejects new questions immediately after stdin has closed', async () => { + const { ctx, input, out } = await setup() + input.finish() + await new Promise(r => setImmediate(r)) + const before = out.text() + + const answer = ctx.userInteraction.ask({ questions: [{ id: 'late', question: 'Too late?' }] }) + + await expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + expect(out.text()).toBe(before) + }) + it('sends a typed line to an idle agent', async () => { const { ctx, input } = await setup() const agent = makeAgent('main', 'idle') diff --git a/packages/ui/stdio-agent/tsconfig.json b/packages/ui/stdio-agent/tsconfig.json index 8b76352d11..6f30c1558e 100644 --- a/packages/ui/stdio-agent/tsconfig.json +++ b/packages/ui/stdio-agent/tsconfig.json @@ -32,6 +32,12 @@ { "path": "../../core/agent-core" }, + { + "path": "../user-interaction" + }, + { + "path": "../tool-ask-user" + }, { "path": "../../session-persistence/session-persistence-jsonl" } diff --git a/packages/ui/tool-ask-user/README.md b/packages/ui/tool-ask-user/README.md new file mode 100644 index 0000000000..10d4a082ba --- /dev/null +++ b/packages/ui/tool-ask-user/README.md @@ -0,0 +1,20 @@ +# @deepseek-ai/dsh-tool-ask-user + +Model-facing `ask_user_question` tool over `ctx.userInteraction`. It lets the model ask the human a concise question when it needs confirmation, a choice, or missing information before continuing. + +## Tool + +`ask_user_question` accepts: + +- `questions` — required non-empty array of question objects. +- `id` — required stable id on each question, echoed in the answer. +- `question` — required question text for each question. +- `header` — optional short heading. +- `options` — optional choices with `label` and `description`. If recommending a choice, put it first and append `(Recommended)` to that label. +- `multi_select` — whether that question may return more than one selected option. + +The tool calls `ctx.userInteraction.ask()` and returns JSON text shaped as `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`. `selected` contains option labels; `custom` is present only for a free-form answer and overrides selected choices. + +## Role + +This is the consumer package for the user-interaction seam. It does not render UI and does not know how input is collected; it only translates model arguments into `AskUserQuestionRequest` and returns the human answer to the agent loop. diff --git a/packages/ui/tool-ask-user/package.json b/packages/ui/tool-ask-user/package.json new file mode 100644 index 0000000000..c1860f48f0 --- /dev/null +++ b/packages/ui/tool-ask-user/package.json @@ -0,0 +1,38 @@ +{ + "name": "@deepseek-ai/dsh-tool-ask-user", + "description": "Model-facing ask_user_question tool over the ctx.userInteraction seam", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "@deepseek-ai/dsh-user-interaction": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-user-interaction": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/ui/tool-ask-user/src/index.ts b/packages/ui/tool-ask-user/src/index.ts new file mode 100644 index 0000000000..2591b28ddd --- /dev/null +++ b/packages/ui/tool-ask-user/src/index.ts @@ -0,0 +1,71 @@ +/** + * Model-facing `ask_user_question` tool over the `ctx.userInteraction` seam. + * The tool pauses until a UI provider returns a human answer, then feeds that + * answer back into the agent loop as an ordinary tool result. + * + * @module @deepseek-ai/dsh-tool-ask-user + */ + +import type { Context } from 'cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' +import '@deepseek-ai/dsh-user-interaction' + +export const name = 'tool-ask-user' +export const inject = ['tools', 'userInteraction'] + +const description = 'Ask the user a concise question when you need confirmation, a choice, or missing information before proceeding. ' + + 'Send one or more questions, each with a stable id that will be echoed in the answer.' + +export function apply(ctx: Context): void { + ctx.tools.register(defineTool({ + name: 'ask_user_question', + description, + parameters: { + questions: { + type: 'array', + required: true, + description: 'Questions to ask the user before continuing.', + items: { + type: 'object', + properties: { + id: { type: 'string', required: true, description: 'Stable id for this question; echoed in the answer.' }, + question: { type: 'string', required: true, description: 'The specific question to ask the user.' }, + header: { + type: 'string', + description: 'Optional short heading for the question, such as "Confirm" or "Choose Mode".', + }, + options: { + type: 'array', + description: 'Optional choices to show the user. If you recommend one, put it first and append "(Recommended)" to that label.', + items: { + type: 'object', + properties: { + label: { type: 'string', required: true, description: 'Short user-facing option label.' }, + description: { type: 'string', description: 'One sentence explaining the tradeoff or impact.' }, + }, + }, + }, + multi_select: { + type: 'boolean', + description: 'Whether the user may select more than one option. Defaults to false.', + }, + }, + }, + }, + }, + async execute(args, exec) { + const result = await ctx.userInteraction.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 } : {}, + ...exec.signal !== undefined ? { signal: exec.signal } : {}, + }) + return [{ type: 'text', text: JSON.stringify(result) }] + }, + })) +} diff --git a/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts b/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts new file mode 100644 index 0000000000..ceff7df388 --- /dev/null +++ b/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts @@ -0,0 +1,253 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import UserInteractionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-interaction' +import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user' + +interface OptionSchemaShape { + properties: { + questions: { + items: { + properties: { + options: { + items: { + properties: Record + } + } + } & Record + } + } + } +} + +async function setup() { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(UserInteractionService) + await ctx.plugin(toolAskUser) + return ctx +} + +describe('ask_user_question tool', () => { + it('registers a model-facing tool schema', async () => { + const ctx = await setup() + const schema = ctx.tools.schemas().find(tool => tool.name === 'ask_user_question') + + expect(schema).toMatchObject({ + name: 'ask_user_question', + parameters: { + type: 'object', + properties: { + questions: { type: 'array' }, + }, + required: ['questions'], + }, + }) + const parameters = schema?.parameters as unknown as OptionSchemaShape + expect(parameters.properties.questions.items.properties).toMatchObject({ + id: { type: 'string' }, + question: { type: 'string' }, + header: { type: 'string' }, + options: { type: 'array' }, + multi_select: { type: 'boolean' }, + }) + expect(parameters.properties.questions.items.properties.options.items.properties).toMatchObject({ + label: { type: 'string' }, + description: { type: 'string' }, + }) + expect(parameters.properties.questions.items.properties.options.items.properties).not.toHaveProperty('value') + expect(parameters.properties.questions.items.properties.options.items.properties).not.toHaveProperty('recommended') + expect(parameters.properties.questions.items.properties.options.items.properties).not.toHaveProperty('preview') + }) + + it('asks the registered user-interaction provider and projects structured answers to text', async () => { + const ctx = await setup() + const seen: AskUserQuestionRequest[] = [] + ctx.userInteraction.registerProvider({ + async ask(request) { + seen.push(request) + return { answers: [{ id: 'pkg', selected: ['pnpm'] }] } + }, + }) + + const result = await ctx.tools.execute({ + callId: CallId('ask-1'), + name: 'ask_user_question', + arguments: { + questions: [{ + id: 'pkg', + question: 'Which package manager should I use?', + options: [{ label: 'pnpm', description: 'Use pnpm workspaces.' }], + }], + }, + }) + + expect(result).toMatchObject({ + isError: false, + content: [{ type: 'text', text: '{"answers":[{"id":"pkg","selected":["pnpm"]}]}' }], + }) + expect(seen).toMatchObject([{ + questions: [{ + id: 'pkg', + question: 'Which package manager should I use?', + options: [{ label: 'pnpm', description: 'Use pnpm workspaces.' }], + }], + }]) + }) + + it('passes recommended option labels through without adding schema fields', async () => { + const ctx = await setup() + const seen: AskUserQuestionRequest[] = [] + ctx.userInteraction.registerProvider({ + async ask(request) { + seen.push(request) + return { answers: [{ id: 'pkg', selected: ['pnpm (Recommended)'] }] } + }, + }) + + await ctx.tools.execute({ + callId: CallId('ask-recommended'), + name: 'ask_user_question', + arguments: { + questions: [{ + id: 'pkg', + question: 'Which package manager should I use?', + options: [ + { label: 'pnpm (Recommended)' }, + { label: 'npm' }, + ], + }], + }, + }) + + expect(seen[0]?.questions[0]?.options).toEqual([ + { label: 'pnpm (Recommended)' }, + { label: 'npm' }, + ]) + }) + + it('projects custom answers and multi-select choices', async () => { + const ctx = await setup() + ctx.userInteraction.registerProvider({ + async ask() { + return { + answers: [ + { id: 'targets', selected: ['tests', 'docs'] }, + { id: 'notes', selected: [], custom: 'ship today' }, + ], + } + }, + }) + + const result = await ctx.tools.execute({ + callId: CallId('ask-multi'), + name: 'ask_user_question', + arguments: { + questions: [ + { + id: 'targets', + question: 'What should I update?', + options: [{ label: 'tests' }, { label: 'docs' }], + multi_select: true, + }, + { id: 'notes', question: 'Any note?' }, + ], + }, + }) + + expect(result.content).toEqual([{ + type: 'text', + text: '{"answers":[{"id":"targets","selected":["tests","docs"]},{"id":"notes","selected":[],"custom":"ship today"}]}', + }]) + }) + + it('passes the tool abort signal to the user-interaction request', async () => { + const ctx = await setup() + const seen: AskUserQuestionRequest[] = [] + ctx.userInteraction.registerProvider({ + async ask(request) { + seen.push(request) + return { answers: [{ id: 'continue', selected: ['ok'] }] } + }, + }) + const controller = new AbortController() + + await ctx.tools.execute({ + callId: CallId('ask-2'), + name: 'ask_user_question', + arguments: { questions: [{ id: 'continue', question: 'Continue?' }] }, + signal: controller.signal, + }) + + expect(seen[0]?.signal).toBe(controller.signal) + }) + + it('passes optional header and agent through to the user-interaction request', async () => { + const ctx = await setup() + const seen: AskUserQuestionRequest[] = [] + ctx.userInteraction.registerProvider({ + async ask(request) { + seen.push(request) + return { answers: [{ id: 'continue', selected: ['ok'] }] } + }, + }) + const agent = { id: 'main' } as unknown as Agent + + const result = await ctx.tools.execute({ + callId: CallId('ask-3'), + name: 'ask_user_question', + arguments: { questions: [{ id: 'continue', header: 'Confirm', question: 'Continue?' }] }, + agent, + }) + + expect(result.content).toEqual([{ type: 'text', text: '{"answers":[{"id":"continue","selected":["ok"]}]}' }]) + expect(seen[0]).toMatchObject({ questions: [{ id: 'continue', header: 'Confirm', question: 'Continue?' }], agent }) + }) + + it('returns structured user-interaction errors through tool execution', async () => { + const ctx = await setup() + + const result = await ctx.tools.execute({ + callId: CallId('ask-no-provider'), + name: 'ask_user_question', + arguments: { questions: [{ id: 'continue', question: 'Continue?' }] }, + }) + + expect(result).toMatchObject({ + isError: true, + error: { name: 'UserInteractionError', code: 'NO_PROVIDER' }, + }) + }) + + it('returns a structured error for empty question batches', async () => { + const ctx = await setup() + + const result = await ctx.tools.execute({ + callId: CallId('ask-empty'), + name: 'ask_user_question', + arguments: { questions: [] }, + }) + + expect(result).toMatchObject({ + isError: true, + error: { name: 'UserInteractionError', code: 'EMPTY_QUESTIONS' }, + }) + }) + + it('unregisters the tool when its plugin fiber is disposed', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(UserInteractionService) + const fiber = await ctx.plugin(toolAskUser) + expect(ctx.tools.get('ask_user_question')).toBeDefined() + + await fiber.dispose() + + expect(ctx.tools.get('ask_user_question')).toBeUndefined() + }) +}) diff --git a/packages/ui/tool-ask-user/tsconfig.json b/packages/ui/tool-ask-user/tsconfig.json new file mode 100644 index 0000000000..c779bad37f --- /dev/null +++ b/packages/ui/tool-ask-user/tsconfig.json @@ -0,0 +1,36 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../../core/tools" + }, + { + "path": "../user-interaction" + } + ] +} diff --git a/packages/ui/user-interaction/README.md b/packages/ui/user-interaction/README.md new file mode 100644 index 0000000000..6377c2b7ef --- /dev/null +++ b/packages/ui/user-interaction/README.md @@ -0,0 +1,24 @@ +# @deepseek-ai/dsh-user-interaction + +Abstract user-interaction seam. It owns `ctx.userInteraction`, the service a model-facing tool or permission plugin uses when it needs to pause work and ask the human for a decision. + +## Service: `UserInteractionService` (ctx key: `userInteraction`) + +### Public API + +- `ctx.userInteraction.registerProvider(provider): () => void` Register the UI-side provider. Only one provider may be active in a context; disposal unregisters it. +- `ctx.userInteraction.ask(request): Promise` Ask the active provider and wait for the answer. + +### Key Types + +- `AskUserQuestionRequest` — `{ questions: [{ id, question, header?, options?, multiSelect? }], agent?, signal? }`. +- `AskUserQuestionOption` — `{ label, description? }`. +- `AskUserQuestionAnswer` — `{ answers: [{ id, selected, custom? }] }`. +- `UserInteractionProvider` — UI implementation with `ask(request)`. +- `UserInteractionError` — `HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `NO_PROVIDER`, `DUPLICATE_PROVIDER`, and `ASK_ABORTED`. + +When an answer includes `custom`, `selected` is empty; custom text is an override rather than a supplement to selected choices. + +## Role + +This is the interface package. Model-facing consumers such as `@deepseek-ai/dsh-tool-ask-user` depend on this seam; UI front doors such as the `stdio-agent` readline module and the `acp` bridge provide the provider. The loop stays unchanged: a tool call simply awaits a promise, and the tool result resumes the normal agent loop. diff --git a/packages/ui/user-interaction/package.json b/packages/ui/user-interaction/package.json new file mode 100644 index 0000000000..f333195ac7 --- /dev/null +++ b/packages/ui/user-interaction/package.json @@ -0,0 +1,34 @@ +{ + "name": "@deepseek-ai/dsh-user-interaction", + "description": "Abstract user-interaction seam (ctx.userInteraction) for asking the human during agent runs", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/ui/user-interaction/src/index.ts b/packages/ui/user-interaction/src/index.ts new file mode 100644 index 0000000000..f9c1616ade --- /dev/null +++ b/packages/ui/user-interaction/src/index.ts @@ -0,0 +1,128 @@ +/** + * User-interaction seam (`ctx.userInteraction`): a UI-backed service for + * pausing an agent tool call until the human answers a question. The model- + * facing tool lives in `@deepseek-ai/dsh-tool-ask-user`; UI packages provide + * the single active provider. + * + * @module @deepseek-ai/dsh-user-interaction + */ + +import { Context, Service } from 'cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { HarnessError } from '@deepseek-ai/dsh-llm' + +declare module 'cordis' { + interface Context { + userInteraction: UserInteractionService + } +} + +/** One selectable answer offered to the user. */ +export interface AskUserQuestionOption { + /** User-facing label. */ + label: string + /** Optional extra context rendered by capable UIs. */ + description?: string +} + +/** One question in an ask_user_question request. */ +export interface AskUserQuestionItem { + /** Stable model-provided question id, echoed in the answer. */ + id: string + /** 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 more than one option may be selected. Defaults to single-select. */ + multiSelect?: boolean +} + +/** Request for a human answer. */ +export interface AskUserQuestionRequest { + /** Questions to display. */ + questions: AskUserQuestionItem[] + /** Calling agent, when the request came from an agent tool call. */ + agent?: Agent + /** Abort signal for the owning tool/step. */ + signal?: AbortSignal +} + +/** Answer to one question. */ +export interface AskUserQuestionAnswerItem { + /** The answered question id. */ + id: string + /** Selected option labels. Empty when the answer is purely custom text. */ + selected: string[] + /** Optional free-text "Other" answer. */ + custom?: string +} + +/** The human's answer. */ +export interface AskUserQuestionAnswer { + /** Structured answers keyed by question id. */ + answers: AskUserQuestionAnswerItem[] +} + +/** UI-side provider for user questions. */ +export interface UserInteractionProvider { + ask(request: AskUserQuestionRequest): Promise +} + +/** Stable error taxonomy for user-interaction failures. */ +export class UserInteractionError extends HarnessError { + constructor(message: string, code: string, options?: ErrorOptions) { + super(message, code, options) + this.name = 'UserInteractionError' + } +} + +/** `ctx.userInteraction`: one active UI provider plus an `ask()` surface. */ +export class UserInteractionService extends Service { + private provider: UserInteractionProvider | undefined + + constructor(ctx: Context) { + super(ctx, 'userInteraction') + } + + /** + * Register the UI provider. Only one provider may be active in a context. + * + * @param provider UI-side implementation that collects answers. + * @returns Disposer that unregisters this provider. + */ + registerProvider(provider: UserInteractionProvider): () => void { + const dispose = this.ctx.effect(function* (this: UserInteractionService) { + if (this.provider !== undefined) { + throw new UserInteractionError('a user-interaction provider is already registered', 'DUPLICATE_PROVIDER') + } + this.provider = provider + yield () => { + this.provider = undefined + } + }.bind(this), 'userInteraction.registerProvider()') + return () => void dispose() + } + + /** + * Ask the active UI provider and wait for the user's answer. + * + * @param request Questions, owner agent, and abort signal. + * @returns The answer chosen or typed by the human. + */ + async ask(request: AskUserQuestionRequest): Promise { + if (request.signal?.aborted) { + throw new UserInteractionError('ask_user_question was aborted before the user answered', 'ASK_ABORTED') + } + if (request.questions.length === 0) { + throw new UserInteractionError('ask_user_question requires at least one question', 'EMPTY_QUESTIONS') + } + if (this.provider === undefined) { + throw new UserInteractionError('no user-interaction provider is registered', 'NO_PROVIDER') + } + return this.provider.ask(request) + } +} + +export default UserInteractionService diff --git a/packages/ui/user-interaction/tests/user-interaction.spec.ts b/packages/ui/user-interaction/tests/user-interaction.spec.ts new file mode 100644 index 0000000000..adfbc9d4bb --- /dev/null +++ b/packages/ui/user-interaction/tests/user-interaction.spec.ts @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import UserInteractionService, { + UserInteractionError, + type AskUserQuestionRequest, + type UserInteractionProvider, +} from '@deepseek-ai/dsh-user-interaction' + +function provider(answer = 'approved'): UserInteractionProvider & { seen: AskUserQuestionRequest[] } { + const seen: AskUserQuestionRequest[] = [] + return { + seen, + async ask(request) { + seen.push(request) + return { answers: [{ id: request.questions[0]?.id ?? 'missing', selected: [answer] }] } + }, + } +} + +describe('UserInteractionService', () => { + it('delegates ask requests to the registered provider', async () => { + const ctx = new Context() + await ctx.plugin(UserInteractionService) + const p = provider('yes') + ctx.userInteraction.registerProvider(p) + + const result = await ctx.userInteraction.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }] }) + + expect(result).toEqual({ answers: [{ id: 'confirm', selected: ['yes'] }] }) + expect(p.seen).toEqual([{ questions: [{ id: 'confirm', question: 'Proceed?' }] }]) + }) + + it('rejects ask requests when no provider is registered', async () => { + const ctx = new Context() + await ctx.plugin(UserInteractionService) + + await expect(ctx.userInteraction.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }] })) + .rejects.toMatchObject({ name: 'UserInteractionError', code: 'NO_PROVIDER' }) + }) + + it('registers providers with HMR-safe disposal', async () => { + const ctx = new Context() + await ctx.plugin(UserInteractionService) + const p = provider() + const dispose = ctx.userInteraction.registerProvider(p) + + dispose() + dispose() + + await expect(ctx.userInteraction.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }] })) + .rejects.toMatchObject({ code: 'NO_PROVIDER' }) + }) + + it('rejects duplicate providers instead of replacing the active UI', async () => { + const ctx = new Context() + await ctx.plugin(UserInteractionService) + ctx.userInteraction.registerProvider(provider('first')) + + expect(() => ctx.userInteraction.registerProvider(provider('second'))) + .toThrow(UserInteractionError) + }) + + it('fails before reaching the provider when the signal is already aborted', async () => { + const ctx = new Context() + await ctx.plugin(UserInteractionService) + const p = { ask: vi.fn(async () => ({ answers: [{ id: 'confirm', selected: ['too late'] }] })) } + ctx.userInteraction.registerProvider(p) + const controller = new AbortController() + controller.abort() + + await expect(ctx.userInteraction.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }], signal: controller.signal })) + .rejects.toMatchObject({ code: 'ASK_ABORTED' }) + expect(p.ask).not.toHaveBeenCalled() + }) + + it('rejects empty question batches before reaching the provider', async () => { + const ctx = new Context() + await ctx.plugin(UserInteractionService) + const p = { ask: vi.fn(async () => ({ answers: [] })) } + ctx.userInteraction.registerProvider(p) + + await expect(ctx.userInteraction.ask({ questions: [] })) + .rejects.toMatchObject({ name: 'UserInteractionError', code: 'EMPTY_QUESTIONS' }) + expect(p.ask).not.toHaveBeenCalled() + }) +}) diff --git a/packages/ui/user-interaction/tsconfig.json b/packages/ui/user-interaction/tsconfig.json new file mode 100644 index 0000000000..178ff39f3f --- /dev/null +++ b/packages/ui/user-interaction/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../llm/llm" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab1b0f73b3..e004932463 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -354,6 +354,18 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/ui/user-interaction: + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/fs/fs: devDependencies: '@deepseek-ai/dsh-brand': @@ -978,6 +990,9 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-ask-user': + specifier: workspace:^ + version: link:../tool-ask-user '@deepseek-ai/dsh-tool-bash': specifier: workspace:^ version: link:../../bash/tool-bash @@ -990,6 +1005,9 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools + '@deepseek-ai/dsh-user-interaction': + specifier: workspace:^ + version: link:../user-interaction cordis: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) @@ -1017,6 +1035,9 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt + '@deepseek-ai/dsh-user-interaction': + specifier: workspace:^ + version: link:../user-interaction cordis: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) @@ -1068,6 +1089,12 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-ask-user': + specifier: workspace:^ + version: link:../tool-ask-user + '@deepseek-ai/dsh-user-interaction': + specifier: workspace:^ + version: link:../user-interaction cordis: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) @@ -1075,6 +1102,27 @@ importers: specifier: ^3.17.0 version: 3.18.0 + packages/ui/tool-ask-user: + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + '@deepseek-ai/dsh-user-interaction': + specifier: workspace:^ + version: link:../user-interaction + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/util/brand: devDependencies: cordis: diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index d8d320df33..a1c67b6e62 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -122,9 +122,18 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'tools', title: 'Tool registry and execution waterfall', mode: 'core', - consumers: ['agent-loop', 'tool-bash', 'tool-cordis', 'tool-fs', 'tool-subagent', 'tool-todo', 'tool-web', 'acp'], + consumers: ['agent-loop', 'tool-ask-user', 'tool-bash', 'tool-cordis', 'tool-fs', 'tool-subagent', 'tool-todo', 'tool-web', 'acp'], note: 'Registers tool definitions, exposes schemas to the prompt, and routes calls through tools/pre-execute and tools/post-execute.', }, + { + key: 'userInteraction', + pkg: 'user-interaction', + title: 'Human question/answer seam', + mode: 'seam', + implementations: ['stdio-agent', 'acp'], + consumers: ['tool-ask-user', 'stdio-agent', 'acp'], + note: 'UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise.', + }, { key: 'agents', pkg: 'agent', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 714b15afdc..c261f69834 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -41,11 +41,13 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import LocalBashExecutor from '@deepseek-ai/dsh-bash-local' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' +import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import WebService from '@deepseek-ai/dsh-web' import * as WebSearchExa from '@deepseek-ai/dsh-web-search-exa' import * as WebFetchLocal from '@deepseek-ai/dsh-web-fetch-local' import SubagentService from '@deepseek-ai/dsh-subagent' import * as SubagentMock from '@deepseek-ai/dsh-subagent-mock' +import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' @@ -101,6 +103,19 @@ interface ToolPackage { * guard proves it is exhaustive against the on-disk glob. */ const TOOL_PACKAGES: ToolPackage[] = [ + { + pkg: '@deepseek-ai/dsh-tool-ask-user', + dir: 'tool-ask-user', + source: 'packages/ui/tool-ask-user/src/index.ts', + requires: ['ctx.tools', 'ctx.userInteraction'], + writes: ['tool/call', 'tool/result after a UI/provider answers the question'], + async mount(ctx) { + await ctx.plugin(UserInteractionService) + await ctx.plugin(ToolAskUser) + }, + note: + 'ask_user_question pauses the tool call until the active UI provider returns a human answer.', + }, { pkg: '@deepseek-ai/dsh-tool-bash', dir: 'tool-bash', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 613280ee1b..2d498e8b64 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -48,6 +48,14 @@ { "doc": "docs/core-data-structures/tools.md", "symbol": "StructuredSchemaNode", "source": "packages/core/tools/src/json-schema.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "StructuredOutputSchema", "source": "packages/core/tools/src/json-schema.ts" }, + { "doc": "docs/core-data-structures/user-interaction.md", "symbol": "AskUserQuestionOption", "source": "packages/ui/user-interaction/src/index.ts" }, + { "doc": "docs/core-data-structures/user-interaction.md", "symbol": "AskUserQuestionItem", "source": "packages/ui/user-interaction/src/index.ts" }, + { "doc": "docs/core-data-structures/user-interaction.md", "symbol": "AskUserQuestionRequest", "source": "packages/ui/user-interaction/src/index.ts" }, + { "doc": "docs/core-data-structures/user-interaction.md", "symbol": "AskUserQuestionAnswerItem", "source": "packages/ui/user-interaction/src/index.ts" }, + { "doc": "docs/core-data-structures/user-interaction.md", "symbol": "AskUserQuestionAnswer", "source": "packages/ui/user-interaction/src/index.ts" }, + { "doc": "docs/core-data-structures/user-interaction.md", "symbol": "UserInteractionProvider", "source": "packages/ui/user-interaction/src/index.ts" }, + { "doc": "docs/core-data-structures/user-interaction.md", "symbol": "UserInteractionError", "source": "packages/ui/user-interaction/src/index.ts" }, + { "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecRequest", "source": "packages/bash/bash/src/types.ts" }, { "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecSpec", "source": "packages/bash/bash/src/types.ts" }, { "doc": "docs/core-data-structures/bash.md", "symbol": "BashRunResult", "source": "packages/bash/bash/src/types.ts" }, diff --git a/tsconfig.build.json b/tsconfig.build.json index fafe46d3c9..8d850dd50f 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -19,7 +19,9 @@ { "path": "./packages/session-persistence/session-persistence-sqlite" }, { "path": "./packages/core/system-prompt" }, { "path": "./packages/core/agent" }, + { "path": "./packages/ui/user-interaction" }, { "path": "./packages/core/tools" }, + { "path": "./packages/ui/tool-ask-user" }, { "path": "./packages/core/agent-loop" }, { "path": "./packages/core/agent-core" }, { "path": "./packages/bash/bash" }, diff --git a/tsconfig.json b/tsconfig.json index 9d630d04c4..d69f55bcdc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,7 +30,9 @@ { "path": "./packages/session-persistence/session-persistence-sqlite" }, { "path": "./packages/core/system-prompt" }, { "path": "./packages/core/agent" }, + { "path": "./packages/ui/user-interaction" }, { "path": "./packages/core/tools" }, + { "path": "./packages/ui/tool-ask-user" }, { "path": "./packages/core/agent-loop" }, { "path": "./packages/core/agent-core" }, { "path": "./packages/bash/bash" },