diff --git a/README.md b/README.md index 0e7367f7..0ed999a9 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ AgentStudio provides a streaming chat interface where the assistant can call too Editing a message or regenerating a reply cuts the conversation back so the model sees only what was kept plus the message it is answering. In a project (or an agent with a persistent workspace) the agent's file changes are checkpointed every turn, and Edit and Regenerate offer to restore the files the dropped replies changed — showing the files and line counts first, and asking again before overwriting uncommitted changes in an imported repository. **Compact Conversation** runs the SDK's own `/compact`. See [docs/chat/chat.md](docs/chat/chat.md). +When the agent needs a decision it asks with the Agent SDK's own AskUserQuestion, shown as a question card: a header chip, option cards with a recommended badge, a sandboxed HTML preview of each option where one helps, multi-select, and a free-text "Other". It can be answered in the chat, from the composer, or from the /review inbox, and never answers itself. See [docs/chat/spec.md](docs/chat/spec.md#questions-from-the-agent). + Chats run on Claude models through the Claude subscription, at no per-token cost. The model pickers — the composer (its model pill and the `/model` command), the default model and each agent's model — offer only models that can actually run here: the current Claude models Claude Code knows, plus, when the operator configures an Anthropic-compatible gateway such as OpenRouter's, the gateway's models, labelled **Gateway · paid** and priced per token in the usage ledger. The gateway is off by default. See [docs/llm/llm.md](docs/llm/llm.md). The message box has two shortcuts. Typing `@` lists the files in the chat's workspace (a bound project's folder, or an agent's persistent workspace) and inserts the chosen file's relative path. Typing `/` at the start opens a command palette over the app's own actions: `/compact`, `/model`, `/agent`, `/research`, `/plan`, `/effort`, `/attach` and `/voice`. Both work from the keyboard and by tapping on a phone. See "Composer shortcuts" in [docs/chat/spec.md](docs/chat/spec.md). @@ -18,7 +20,7 @@ Replies can be read aloud: a speaker button on each reply plays it through an Op Beside each chat sits a right rail with two tabs: **Preview** (a workspace file or a web page) and **Files** (every file the agent changed in the chat, with +/- counts; click one to preview it). It stays folded to a thin strip until something opens a preview or you expand it, and remembers whether you left it open; on a phone it is a drawer. The context ring and metered cost sit in the chat's header, and each reply links to its run's full tool timeline. See [docs/chat-console/chat-console.md](docs/chat-console/chat-console.md). -Creation workflows are chat-led: New Agent and New Skill actions launch a fresh conversation with a seeded creation prompt. The assistant gathers missing requirements (optionally with ask_user), then executes directly with tool-level approvals where configured. +Creation workflows are chat-led: New Agent and New Skill actions launch a fresh conversation with a seeded creation prompt. The assistant gathers missing requirements (optionally with a question card), then executes directly with tool-level approvals where configured. ### Agents diff --git a/docs/chat/spec.md b/docs/chat/spec.md index 65ec47c9..1c82a3c1 100644 --- a/docs/chat/spec.md +++ b/docs/chat/spec.md @@ -250,7 +250,37 @@ A run's tool calls and events are on its own page, `/runs/`, linked from eac ### Inline approvals and answers -Approval requests and `ask_user` questions render inline in the thread, but are also reflected in the global Review Inbox. Resolving from either place updates the same durable state. +Approval requests and the agent's questions render inline in the thread, but are also reflected in the global Review Inbox. Resolving from either place updates the same durable state. + +### Questions from the agent + +When the agent needs a decision from you before it carries on, it asks with **AskUserQuestion** — the Agent SDK's own question tool, which replaced AgentStudio's home-made `ask_user` in September 2026 (#4). A call holds one to four questions. + +**What the card shows.** Each question gets: + +| Part | What it is | +| --- | --- | +| Header chip | A short label for the question ("Layout", "Auth method") | +| Question | The full question, in a sentence | +| Option cards | Two to four choices, each with a label and a line saying what it means. The one the agent recommends carries a **Recommended** badge | +| Preview | For options that are easier to compare by eye (a layout, a snippet, a configuration), a small rendered picture of what the choice produces. It shows the option under your pointer or keyboard, otherwise the one you chose, otherwise the recommended one. Beside the options on a wide screen, below them on a phone | +| Other | Always there: type your own answer instead of — or, on a "choose any" question, as well as — the options | + +Some questions say **Choose any that apply**: tick as many options as you like. The rest take one answer, and typing in Other (or clicking Other itself) replaces the option you picked. Just moving through Other with the keyboard changes nothing, so you can pick an option and Tab on to Submit. With several questions the card steps through them (Question 1/3, Next, Submit), and Submit waits until every question has an answer. + +**How you answer.** In the card, in the chat composer (typing a reply while a question is waiting answers it), or from the /review inbox, which shows the same card. Whichever comes first counts. Once the server has recorded it, the card shows each question with your answer under it, and so does the conversation after a reload. + +**Several questions at once.** The agent can ask more than one AskUserQuestion in the same step, so two or three cards can be waiting side by side. Each card sends its answer for its own question only. A reply typed in the composer (or given in the modal) goes to the newest card still waiting; once that one is answered, the next one still waiting takes its place. + +**If nobody answers.** The question waits five minutes, like a tool approval. After a minute you get a "Needs input" notification. After five, the agent is told nobody answered and that it must not assume an answer — it carries on only with what does not depend on it, or ends its turn and says what it needs. Stopping the run settles the question at once. A question never answers itself: the SDK's own idle auto-continue (`askUserQuestionTimeout`) is set to `never`, because on a box that runs while you sleep an auto-picked option is a decision you never saw. + +**Who can ask.** Only the agent in a chat. A delegated subagent cannot — it is told to put the question in its result so the agent that delegated to it can ask. Automations, monitors and CI-fix runs have no one to ask and are never offered the tool; if the model tries anyway the call is refused, and the run does not wait. + +**Previews are safe to show.** A preview is HTML the model wrote, and the model may be repeating text from a web page or a repository. So it is never put into the page. It is shown in a sealed frame that cannot run scripts, cannot reach the app or your session, cannot submit forms or open windows, and cannot load anything from the internet — an image pointing at someone's server is simply not fetched. Tags that could act on the frame itself are shown as text. A preview over 12,000 characters is dropped. + +**What was checked in the SDK** (`@anthropic-ai/claude-agent-sdk` 0.3.278, bundled CLI 2.1.278), rather than assumed: `toolConfig.askUserQuestion.previewFormat` exists and is set to `html` (the default is markdown, for a terminal); `askUserQuestionTimeout` is a Settings field, reached through the `settings` option; the tool always asks permission, so each call reaches the app's permission callback, which answers it by returning the call's input with the answers added (question text to answer, several choices comma-separated); and the tool is only switched on when a permission callback is installed, which the chat engine always does. The CLI also reads back and reports the answers it was given, which is what the saved transcript shows. + +Transcripts from before the change keep their `ask_user` blocks, and those still show each question with its answer. ### Diff and artifact preview @@ -299,7 +329,7 @@ How the controls that exist today behave: - **Coming back to a running turn.** Opening a conversation whose turn is still running — after a reload, or from another tab — shows that turn streaming again, with its tool and approval cards and the Stop button. Text written before you came back appears once the turn finishes. - **One turn at a time.** A message sent while a turn is still running is not sent. The page says so, keeps the message for Retry, and shows the running turn instead. - **Allow / Deny.** An approval card only shows a call as approved or denied once the server has recorded the answer. If it could not be recorded (the approval timed out, or was answered in another tab), the card keeps its buttons and says why. -- **Answering a question.** When the agent asks a question (`ask_user`), the answer only counts once the server has recorded it. If the question is no longer waiting (it timed out, was answered in another tab, or its turn ended), the page says so and shows the conversation as the server has it, rather than closing the question as if the answer had gone through. An answered question shows the answer under it straight away, and again after a reload. It no longer keeps a live Submit button that does nothing. +- **Answering a question.** When the agent asks a question (see [Questions from the agent](#questions-from-the-agent)), the answer only counts once the server has recorded it. If the question is no longer waiting (it timed out, was answered in another tab, or its turn ended), the page says so and shows the conversation as the server has it, rather than closing the question as if the answer had gone through. An answered question shows the answer under it straight away, and again after a reload. It no longer keeps a live Submit button that does nothing. - **Switching conversations mid-turn.** Opening another conversation while a reply is streaming shows only the other conversation. Nothing from the first one comes along: not its reply, its tool cards, its Stop button, its error message or its Retry. Leaving is not a Stop. The first turn keeps running, saves its own reply, and shows again with Stop when you go back to it. - **A turn that ends in an error.** Some turns end in an error after the reply was already saved, for example when the agent reaches its maximum number of steps or the model provider is overloaded. The page shows the error and keeps the one saved reply. It used to save a second, partial copy of the same reply. - **Background tasks.** A command the agent starts in the background (a dev server, a watcher) shows as a chip in the header while the turn runs, with a button to stop it. The chips go away when the turn's stream ends, because ending a turn also ends the commands it started. If a stop does not work, a short message under the header says why — for example that the turn had already ended. @@ -334,7 +364,7 @@ Every assistant reply has a speaker button beside Copy. It reads the reply throu ### How replies are displayed safely -Assistant replies, thinking, subagent results and `ask_user` questions are written by the model, and the model may be repeating text it picked up from a web page, a repository or a tool result. Someone who plants instructions there can try to make the model write HTML that would run inside the app, or an image link that quietly sends data to their server the moment the reply is shown. So replies are displayed as formatted markdown, but under these rules: +Assistant replies, thinking, subagent results and the agent's questions are written by the model, and the model may be repeating text it picked up from a web page, a repository or a tool result. Someone who plants instructions there can try to make the model write HTML that would run inside the app, or an image link that quietly sends data to their server the moment the reply is shown. So replies are displayed as formatted markdown, but under these rules: | Content in a reply | What the reader sees | | ------------------ | -------------------- | @@ -391,7 +421,7 @@ This domain follows [../ui/spec.md](../ui/spec.md) and defines the primary app-s - Surfaces: session list (with agent filter, project grouping, and expandable run tree), chat thread canvas, composer, mode selector, live run HUD, inline action cards, and the right rail (Preview + Files). - States and badges: running, blocked, needs-input, queued interjection, completed, failed, and pending approvals count. -- Blocking actions: plan approvals, tool approvals, and ask_user responses must resolve through durable review items. +- Blocking actions: plan approvals, tool approvals, and answers to the agent's questions must resolve through durable review items. - Mobile behavior: the right rail opens as a drawer from the chat header; blocking cards remain visible near composer; session tree uses progressive disclosure to avoid deep nested panes. ## References diff --git a/docs/hooks/spec.md b/docs/hooks/spec.md index ce4258fa..3d108b1a 100644 --- a/docs/hooks/spec.md +++ b/docs/hooks/spec.md @@ -53,7 +53,7 @@ Not every event below is raised yet. Which ones fire today, on the chat path and | `on_evaluator` | `runId`, `verdict`, `findings` | On evaluator result | | `on_subagent_spawn` | `parentRunId`, `childRunId`, `agentId` | On `run_subagent` call | | `on_approval_required` | `runId`, `toolName`, `args`, `token` | On pending approval | -| `on_user_question` | `runId`, `questions`, `token` | On `ask_user` call | +| `on_user_question` | `runId`, `questions`, `token` | When the agent asks a question (AskUserQuestion) | | `on_run_failed` | `runId`, `error` | On run error | | `on_skill_loaded` | `runId`, `skillSlug`, `loadKind` | When a skill summary or body is loaded | | `on_tool_output_archived` | `runId`, `toolName`, `handle`, `wasSummarized` | When large tool output is offloaded | diff --git a/docs/notifications/spec.md b/docs/notifications/spec.md index 16e428e5..4978db7c 100644 --- a/docs/notifications/spec.md +++ b/docs/notifications/spec.md @@ -55,7 +55,7 @@ Notification preferences are stored in `appSettings.notificationPrefs` and contr | Key | Switch in Settings | What it sends today | | --------------- | ------------------ | ------------------- | | `taskCompleted` | Task completed | A research run finished its report | -| `needsInput` | Needs input | A chat run has been waiting for a tool approval or an answer to an `ask_user` question for a minute. Not straight away: someone watching the chat answers in seconds. The run gives up after five minutes, so this leaves about four to answer. The notification links to the chat | +| `needsInput` | Needs input | A chat run has been waiting for a tool approval or an answer to one of the agent's questions for a minute. Not straight away: someone watching the chat answers in seconds. The run gives up after five minutes, so this leaves about four to answer. The notification links to the chat | | `agentErrors` | Agent errors | An automation run failed for good, or a check failed on a pull request an agent opened | Two kinds of notification are sent whatever these switches say, because the user set each one up on purpose and turns it off where they set it up: @@ -87,7 +87,7 @@ All notifications are persisted to the `notifications` table regardless of wheth - Mark individual or all as read - Notifications older than 90 days are soft-hidden (not deleted) -The inbox is the fallback for users without push subscriptions. For approvals and `ask_user` blocks, the chat session UI is the primary surface — the notification is a secondary signal for users who are not actively watching the chat. +The inbox is the fallback for users without push subscriptions. For approvals and the agent's questions, the chat session UI is the primary surface — the notification is a secondary signal for users who are not actively watching the chat. ## Web Push Setup diff --git a/docs/observability/spec.md b/docs/observability/spec.md index 5ea6b9f3..d34ecec2 100644 --- a/docs/observability/spec.md +++ b/docs/observability/spec.md @@ -66,7 +66,7 @@ Time-series metrics storage. Rolled up from run events, job logs, and hook invoc | Item type | Severity | Triggered by | | ------------------------- | -------- | ----------------------------------------------------------- | | `approval_request` | critical | Tool call requires human approval | -| `user_question` | critical | `ask_user` tool call waiting for answer | +| `user_question` | critical | A question from the agent (AskUserQuestion) waiting for an answer — answerable from the inbox with the same card as the chat | | `evaluation_failure` | warning | Evaluator returned `fail` verdict | | `job_failure` | warning | Job exhausted all retry attempts | | `job_stuck` | warning | Job whose worker kept dying mid-run, out of attempts | diff --git a/docs/runs/spec.md b/docs/runs/spec.md index f16b2650..2e2c3f07 100644 --- a/docs/runs/spec.md +++ b/docs/runs/spec.md @@ -21,7 +21,7 @@ A run is the durable, resumable record of one agent execution attempt. Every mes | `model` | string | Model slug used | | `state` | enum | `pending`, `running`, `awaiting_approval`, `awaiting_answer`, `completed`, `failed`, `canceled` | | `pendingApprovals` | jsonb[] | Outstanding tool approval requests (token, toolName, args, requestedAt) | -| `pendingQuestions` | jsonb[] | Outstanding `ask_user` requests (token, questions, requestedAt) | +| `pendingQuestions` | jsonb[] | Outstanding questions from the agent (token, questions, requestedAt, then answers and decidedAt) | | `streamBlocks` | jsonb[] | Incremental content blocks appended as the loop runs | | `currentRound` | integer | Tool loop round counter for resume | | `cursor` | jsonb | Loop resume pointer (last persisted message index, last tool call) | @@ -117,11 +117,16 @@ The chat's Allow / Deny card can appear a moment before step 1 has happened. An ### Pending user questions -`ask_user` follows the same pattern as approvals, written to `runs.pendingQuestions`, resolved via the answer endpoint. +A question from the agent (the SDK's AskUserQuestion, #4) follows the same pattern as approvals: written to `runs.pendingQuestions`, resolved via the answer endpoint or /review, and the run moves to `waiting_user_input` while it waits. + +- Each entry keeps the questions as the card shows them — header, question text, options with descriptions, the recommended marker, any HTML previews, and whether several choices are allowed — so a reloaded page and the /review inbox show the same card. It is JSON, so none of this needed a migration. +- Answers are recorded keyed by each question's text, which is what the SDK takes back. An entry left by the retired `ask_user` is keyed by its header instead, and still reads. +- The token is `:ask:`, one per question call. +- The run waits five minutes, then the agent is told nobody answered. A Stop ends the wait at once and closes the question's inbox item. ### Approvals and questions in the review inbox -Each pending approval and each `ask_user` question also opens an item in the /review inbox, so a user who is not watching the chat can find it (and, after a minute, gets a "Needs input" notification). The item can be answered from /review — Approve, Deny, or the answer card — with the same effect as answering in the chat. +Each pending approval and each question from the agent also opens an item in the /review inbox, so a user who is not watching the chat can find it (and, after a minute, gets a "Needs input" notification). The item can be answered from /review — Approve, Deny, or the answer card — with the same effect as answering in the chat. The item closes by itself when the prompt is settled: answered in either place (resolved, with who answered), timed out after five minutes (dismissed), or left behind by a run that ended or was reaped (dismissed by the check that runs with the reaper every five minutes). Before 2026-09-23 these items were never closed, and answering one in /review did not reach the run. diff --git a/docs/runtime/spec.md b/docs/runtime/spec.md index f4e6ed77..a260f623 100644 --- a/docs/runtime/spec.md +++ b/docs/runtime/spec.md @@ -57,7 +57,7 @@ A stateful event bus for one run. The Session is the only thing that touches SSE | `getMessages` | () => Promise\ | Loads current message history | | `appendMessage` | (m) => Promise\ | Persists a message | | `pendingApproval` | (req) => Promise\ | Blocks until user approves or denies a tool call | -| `pendingQuestion` | (req) => Promise\ | Blocks until user answers via `ask_user` | +| `pendingQuestion` | (req) => Promise\ | Retired with `ask_user` (#4): these runs are unattended and never ask; chat questions are the SDK's AskUserQuestion | ## Features diff --git a/docs/settings/spec.md b/docs/settings/spec.md index 89154617..f8793b85 100644 --- a/docs/settings/spec.md +++ b/docs/settings/spec.md @@ -72,7 +72,7 @@ Each limit is enforced as a budget limit (see [../cost/spec.md](../cost/spec.md) } ``` -The Tool Approval panel lists every AgentStudio tool a chat can call, and each one can be ticked on its own, except three that always ask: `push_branch`, `create_pull_request` and `request_plan_approval`. Those show ticked, marked "always asks", and cannot be unticked, and the **All** and **None** buttons leave them alone, because they ask for approval in every mode whatever is stored. `ask_user` is left off, because it is a question to you rather than an action, and no approval setting ever reaches it. The panel's old "Always loaded" and "Searchable" groups and its "Programmatic tool calling" switch are gone, because none of them did anything in a chat (#8, #69). A stored row may still carry `programmaticToolCallingEnabled`; nothing reads it, and the next save drops it. See [../tools/tools.md](../tools/tools.md). +The Tool Approval panel lists every AgentStudio tool a chat can call, and each one can be ticked on its own, except three that always ask: `push_branch`, `create_pull_request` and `request_plan_approval`. Those show ticked, marked "always asks", and cannot be unticked, and the **All** and **None** buttons leave them alone, because they ask for approval in every mode whatever is stored. Questions from the agent are not on the list: they are the SDK's own AskUserQuestion rather than an AgentStudio tool (#4), and a question to you is not an action, so no approval setting ever reaches it. The panel's old "Always loaded" and "Searchable" groups and its "Programmatic tool calling" switch are gone, because none of them did anything in a chat (#8, #69). A stored row may still carry `programmaticToolCallingEnabled`; nothing reads it, and the next save drops it. See [../tools/tools.md](../tools/tools.md). **`memoryConfig`** diff --git a/docs/structure/boundaries.md b/docs/structure/boundaries.md index da676ce7..4021ecf9 100644 --- a/docs/structure/boundaries.md +++ b/docs/structure/boundaries.md @@ -21,7 +21,7 @@ The narrow LLM-provider surface. Only this module knows OpenRouter exists. ### `src/lib/runtime/` The pre-engine agent loop. Interactive chat runs on the Claude Agent SDK (`src/lib/engine/`); what still runs here is unattended — automations with an agent attached, a monitor's start_conversation, and CI fix runs (#8). - `loop.server.ts` — `runChatLoop()`. Drives streaming, multi-round tool execution and approval gates. Calls `streamChat()` from `llm/`. -- `tool-handlers.server.ts` — per-tool-call dispatch (ask_user, normal tool). Owns the emit / pushBlock / approval-await flow. +- `tool-handlers.server.ts` — per-tool-call dispatch: the offered-list gate, approval, then the registry. Owns the emit / pushBlock / approval-await flow. (Its `ask_user` branch went with the tool, #4 — these runs are unattended and were never offered it.) - `types.ts` — `Session`, `RunChatLoopInput`, `LoopMessage`. The contract the three callers implement. - `agent-definition.server.ts` — slot assembly + workspace context resolution. - `detached-tools.ts` — the short tool list an unattended run is offered (`web_search`). diff --git a/docs/structure/claude-desktop-parity.md b/docs/structure/claude-desktop-parity.md index fbebd3de..9a5bb507 100644 --- a/docs/structure/claude-desktop-parity.md +++ b/docs/structure/claude-desktop-parity.md @@ -71,6 +71,7 @@ This is the Cowork comparison, and it is the one I got wrong in the first draft: | Web search + fetch | **even** | `web_search`, `web_fetch`, `pdf_read` | same | | Code execution | **n/a** — removed (#69) | `run_code` never ran on the engine path and was deleted rather than rebuilt. The agent writes a script into the run's workspace and runs it with the SDK's `Bash`, sandboxed by bubblewrap in production and approval-gated where bubblewrap is missing. No in-script tool calls, no chart output — a chart is a file shown in the Preview tab | analysis tool / sandboxed Python, renders charts | | File attachments | **even** (#36 fixed) | images inline as base64 content blocks on the SDK's streaming-input prompt; PDFs and other files are staged into the run's sandbox workspace and read with `pdf_read` / `file_read`; anything undeliverable (video, oversized or unsupported images) warns on the message instead of being dropped; files attached on the new-chat page go with the first message (they were silently dropped until 2026-09-23) | images, PDFs, office docs, with extraction | +| Agent asks you a question | **even** (#4 landed) | the SDK's own AskUserQuestion, shown as a card with a header chip, option cards and a recommended badge, sandboxed HTML previews of each option, multi-select and a free-text Other; answerable in the chat, the composer or the /review inbox, and it never answers itself (`askUserQuestionTimeout: 'never'`). Until #4 it was a home-made `ask_user` with no previews or multi-select | the same tool, with previews and an idle auto-continue | | Slash commands and `@` file mentions | **behind** (#22) | `@` lists the files in the folder the chat's next turn runs in (a bound project or a persistent agent workspace), fuzzy-ranked, never through a symlink, and inserts the relative path; `/` is a palette over the app's own actions (`/compact`, `/model`, `/agent`, `/research`, `/plan`, `/effort`, `/attach`, `/voice`). A trusted project's own `.claude/commands` and skills are not listed yet, and an unbound chat has nothing to mention because every turn starts in a fresh folder | `/` lists built-in, project and skill commands; `@` for files and connectors | | Voice dictation | **even** | record → `/api/transcribe`; no live transcript while speaking | same | | Text-to-speech | **even** (#27 fixed) | speaker button on every reply and an opt-in, per-device auto-read for hands-free use, through an OpenRouter speech model and voice chosen in Settings; code is skipped, long replies are chunked, spend is on the ledger and under budget limits | shipped | diff --git a/docs/structure/parity-reaudit-2026-09-22.md b/docs/structure/parity-reaudit-2026-09-22.md index 0b15214c..4b0b5192 100644 --- a/docs/structure/parity-reaudit-2026-09-22.md +++ b/docs/structure/parity-reaudit-2026-09-22.md @@ -253,7 +253,7 @@ plan), **fold** (belongs inside another issue), **delete** (close it). | #17 | Connect external MCP servers | **as filed** | plumbing confirmed trivial; the policy layer is the actual work | | #32 | Multi-agent orchestration | **rebuild** | use SDK `agents` + the Task tool instead of a bespoke fan-out tool | | #5 | Port subagents to SDK subagents | **as filed** — shipped | keystone; `Options.agents` + `Task`, `run_subagent` retired | -| #4 | Native AskUserQuestion | **as filed** | `toolConfig.askUserQuestion.previewFormat` confirmed present | +| #4 | Native AskUserQuestion | **as filed** — shipped | the SDK's tool answered through `canUseTool`; HTML previews, multi-select, Other, timeout `never` | | #18 | Conversation pin/archive/search/export | **as filed**, trimmed | all four are cheap; make archive the default action, not delete | | #22 | Slash commands and `@`-mentions | **split** — `@` shipped, `/` shipped over app actions | `@` lists the next turn's workspace; the `/` palette covers the app's own buttons, with SDK commands still to add | | #38 | Usage digest | **rebuild** | fix the ledger first, then ship the header strip; the digest agent is the last 20% | @@ -512,6 +512,40 @@ timeout: `never` is the right default for a self-hosted single-user box where th may be asleep — an auto-continue that picks an option unattended is exactly the failure this app's `/review` inbox exists to avoid. +**Shipped (#4).** The in-house `ask_user` is gone — schema, handler, the old loop's branch and +the engine's special cases — and the SDK's own AskUserQuestion is on for every chat run: +`toolConfig.askUserQuestion.previewFormat: 'html'` and `settings.askUserQuestionTimeout: +'never'` (a Settings field, not an Option). What the installed SDK (0.3.278, CLI 2.1.278) was +read for rather than assumed: + +- the tool's own permission check always asks, so every call reaches `canUseTool`; the host + answers by allowing with `updatedInput.answers` (question text → answer, multi-select + comma-separated), which the CLI's `call()` reads back and echoes as + `tool_use_result.answers`; +- the CLI only enables the tool in SDK mode when a permission prompt tool is set, which + `canUseTool` always provides (`--permission-prompt-tool stdio`); +- in `html` mode the CLI rejects previews that are whole documents or carry + ` -
+
{#if isAnswered} - {#each questions as question (question.header)} + {#each questions as question (answerKey(question))}
-

{question.question}

+

{question.question}

- {#if (answers?.[question.header] ?? '').trim().length > 0} + {#if answerFor(question)}
-

{answers?.[question.header]}

+

{answerFor(question)}

{/if} {/each} {:else if isWaiting} -
-
+
+
{#if activeQuestion} -
- Question {Math.min(clampedIndex + 1, totalQuestions)}/{totalQuestions || 1} - {activeQuestion.question} -
+ {#if totalQuestions > 1} +
+ Question {clampedIndex + 1}/{totalQuestions} +
+ {/if} - setAnswer(activeQuestion.header, value)} - /> + {#key answerKey(activeQuestion)} + setSelection(activeQuestion, next)} + disabled={submitting} + /> + {/key}
-
- - -
+ {#if totalQuestions > 1} +
+ + +
+ {/if}
{#if clampedIndex < totalQuestions - 1} @@ -142,8 +148,6 @@
{:else} -
- Question timed out without an answer. -
+
Question timed out without an answer.
{/if}
diff --git a/src/lib/chat/AskUserModal.svelte b/src/lib/chat/AskUserModal.svelte index 85690e7a..9a5e685c 100644 --- a/src/lib/chat/AskUserModal.svelte +++ b/src/lib/chat/AskUserModal.svelte @@ -1,62 +1,52 @@ + +
+
+ Preview + {label} +
+ +
diff --git a/src/lib/chat/AskUserQuestionCard.svelte b/src/lib/chat/AskUserQuestionCard.svelte index c02f95f2..79a3a03f 100644 --- a/src/lib/chat/AskUserQuestionCard.svelte +++ b/src/lib/chat/AskUserQuestionCard.svelte @@ -1,77 +1,183 @@ -
- {#if question.options.length > 0} -
+
+
+ {#if question.header} + + {question.header} + + {/if} + {#if multi} + Choose any that apply + {/if} +
+ +

{question.question}

+ +
+
{#each question.options as option (option.label)} + {@const picked = selection.selected.includes(option.label)} {/each} + + {#if allowOther} +
+ + + +
+ {/if}
- {/if} - {#if question.allowFreeformInput ?? true} -
- -
- {/if} + {#if shown?.preview} +
+ +
+ {/if} +
diff --git a/src/lib/chat/MessageBlocks.svelte b/src/lib/chat/MessageBlocks.svelte index e0b595f5..a129ed4d 100644 --- a/src/lib/chat/MessageBlocks.svelte +++ b/src/lib/chat/MessageBlocks.svelte @@ -7,11 +7,11 @@ import ThinkingBlockCard from './ThinkingBlockCard.svelte'; import TodoListCard from './TodoListCard.svelte'; import ToolCallCard from './ToolCallCard.svelte'; + import SavedAskUserBlock from './SavedAskUserBlock.svelte'; + import { isAskUserToolName } from '$lib/engine/ask-user-question'; import { askQuestionAlreadyInMessage, blockHasRenderableOutput, - getAskUserAnswer, - getAskUserQuestions, type SavedBlock, } from './message-bubble-helpers'; @@ -21,7 +21,7 @@ * `MessageBubble.svelte` so the bubble can be a thin layout shell. * * Block-kind dispatch: - * - tool/ask_user → inline question + (if available) the answer bubble + * - tool/AskUserQuestion (or a retired ask_user) → the question + its answer (SavedAskUserBlock) * - tool + details → the card for that shape (diff / terminal / todo) * - tool (other) → ToolCallCard * - notice → RunNoticeCard @@ -30,7 +30,7 @@ * - text → rendered markdown * * `messageId` is used to compose stable per-block keys; `messageContent` is - * the raw assistant text (used to suppress redundant ask_user prompts that + * the raw assistant text (used to suppress redundant question prompts that * already appear in the message body). */ @@ -58,25 +58,8 @@ {#each blocks as block, idx (`${messageId}-block-${idx}`)} - {#if block.kind === 'tool' && block.name === 'ask_user'} - {@const askQuestions = getAskUserQuestions(block.arguments, block.result)} - {#if askQuestions.length > 0} - {#each askQuestions as q} - {#if !askQuestionAlreadyHere(q.question ?? q.header)} -
-
{@html renderMarkdown(q.question ?? q.header)}
-
- {/if} - {@const answer = getAskUserAnswer(block.result, q.header, askQuestions.map((question) => question.header))} - {#if answer} -
-
-

{answer}

-
-
- {/if} - {/each} - {/if} + {#if block.kind === 'tool' && isAskUserToolName(block.name)} + {:else if block.kind === 'tool' && block.details?.kind === 'file_edit'}
@@ -89,7 +72,7 @@
- {:else if block.kind === 'tool' && block.name !== 'ask_user'} + {:else if block.kind === 'tool'}
0}
{#each normalizedToolCalls as call, idx (`${message.id}-${idx}`)} - {#if call.name === 'ask_user'} + {#if isAskUserToolName(call.name)} {@const askQuestions = getAskUserQuestions(call.arguments, call.result)} {#if askQuestions.length > 0} {#each askQuestions as q} diff --git a/src/lib/chat/SavedAskUserBlock.svelte b/src/lib/chat/SavedAskUserBlock.svelte new file mode 100644 index 00000000..7e3bf601 --- /dev/null +++ b/src/lib/chat/SavedAskUserBlock.svelte @@ -0,0 +1,41 @@ + + +{#each exchanges as exchange, i (i)} + {#if !alreadyShown(exchange.question)} +
+
{@html renderMarkdown(exchange.question)}
+
+ {/if} + {#if exchange.answer} +
+
+

{exchange.answer}

+
+
+ {/if} +{/each} +{#if unanswered && exchanges.length > 0} +

Not answered.

+{/if} diff --git a/src/lib/chat/ask-user-answers.ts b/src/lib/chat/ask-user-answers.ts index a3f2356b..fffa4662 100644 --- a/src/lib/chat/ask-user-answers.ts +++ b/src/lib/chat/ask-user-answers.ts @@ -1,11 +1,13 @@ /** - * Reading the user's answers back out of an `ask_user` tool result (#81). + * Reading the user's answers back out of a question block's result (#81). * - * The host hands the model its answers as plain text, one `Header: answer` per question - * (`fulfilAskUser` in the chat stream). That text is also what the transcript records as the - * call's result, live and after a reload. The cards only understood a JSON - * `{ answers: { … } }` result, which nothing produces any more, so an answered question never - * showed its answer. Both shapes are read here; JSON wins when it is there. + * The retired `ask_user` host handed the model its answers as plain text, one + * `Header: answer` per question, and that text is what those transcripts record as the call's + * result. The SDK's AskUserQuestion (#4) carries its answers on the block's `details` + * instead (`./tool-block-helpers`), but a card answered a moment ago still holds the JSON + * `{ answers }` the chat recorded, and old transcripts are full of the text form. The cards + * once understood only the JSON form, so an answered `ask_user` question never showed its + * answer. Both shapes are read here; JSON wins when it is there. * * No imports on purpose: the transcript helpers load this in the plain Playwright loader too. */ diff --git a/src/lib/chat/ask-user-host.server.ts b/src/lib/chat/ask-user-host.server.ts new file mode 100644 index 00000000..360f351a --- /dev/null +++ b/src/lib/chat/ask-user-host.server.ts @@ -0,0 +1,88 @@ +/** + * The chat's answer to the SDK's AskUserQuestion (#4): put the question in front of the user + * and wait for what they choose. + * + * The engine hands a question over from `canUseTool` (`$lib/engine/ask-user-question`); this + * is the chat run's side of that hand-off, and it does what the old `ask_user` host did: + * + * 1. record the question on the run (`chat_runs.pending_questions`) and move the run to + * `waiting_user_input`. Recording it is what opens its /review inbox item and schedules + * the "needs input" notification, so a user who is not watching the chat can still find + * it and answer it there (`$lib/runs/questions.server`); + * 2. send the `ask_user` frame, which the chat page renders as the question card. The frame + * keeps its old name — it means "the agent is asking you", not "this tool ran" — and now + * carries the SDK's tool_use id, so the card, the frame and the call's result share one id; + * 3. wait for the answer, from the card, the composer or /review. The wait is bounded like + * an approval's (`QUESTION_TIMEOUT_MS`) and cut short by a stop; after that the model is + * told nobody answered. Nothing ever answers on the user's behalf. + * + * The store is injectable so a spec can drive the hand-off without a database. + */ + +import { + ASK_USER_QUESTION_TOOL, + answerKey, + type AskQuestion, + type AskUserHost, +} from '$lib/engine/ask-user-question' +import type { PendingQuestionEntry } from '$lib/runs/runs.schema' +import { awaitQuestionAnswers, enqueuePendingQuestion, QUESTION_TIMEOUT_MS } from '$lib/runs/questions.server' +import { markChatRunRunning } from '$lib/runs/run-lifecycle.server' + +export type AskUserStore = { + enqueue: ( + runId: string, + entry: Omit, + transition: { state: 'waiting_user_input'; label: string }, + ) => Promise + awaitAnswers: (runId: string, token: string, signal: AbortSignal) => Promise | null> + markRunning: (runId: string) => Promise +} + +const databaseStore: AskUserStore = { + enqueue: (runId, entry, transition) => enqueuePendingQuestion(runId, entry, transition), + awaitAnswers: (runId, token, signal) => awaitQuestionAnswers(runId, token, QUESTION_TIMEOUT_MS, signal), + markRunning: (runId) => markChatRunRunning(runId), +} + +/** + * The token an answer has to present. One per question call, so two questions in one turn + * never share one, and readable in a log next to the approval tokens (`:`). + */ +export function askUserToken(runId: string, toolUseId: string): string { + return `${runId}:ask:${toolUseId}` +} + +/** A question as it is recorded and sent: keyed, and with "Other" spelled out. */ +export function pendingQuestionsFor(questions: AskQuestion[]): AskQuestion[] { + return questions.map((question) => ({ + ...question, + key: answerKey(question), + allowFreeformInput: question.allowFreeformInput ?? true, + })) +} + +export function createAskUserHost(input: { + runId: string + /** The stream's frame writer — persisted as a run event, so a reconnecting page replays it. */ + emit: (event: string, payload: unknown) => Promise + store?: AskUserStore +}): AskUserHost { + const store = input.store ?? databaseStore + return async ({ toolUseId, questions, signal }) => { + const token = askUserToken(input.runId, toolUseId) + const pending = pendingQuestionsFor(questions) + + await store.enqueue( + input.runId, + { token, questions: pending, requestedAt: new Date().toISOString() }, + { state: 'waiting_user_input', label: 'Waiting for your answer' }, + ) + await input.emit('ask_user', { id: toolUseId, name: ASK_USER_QUESTION_TOOL, token, questions: pending }) + + const answers = await store.awaitAnswers(input.runId, token, signal) + // A stopped run is ending; putting it back to "running" would only flicker. + if (!signal.aborted) await store.markRunning(input.runId) + return { answers } + } +} diff --git a/src/lib/chat/chat-page-commands.ts b/src/lib/chat/chat-page-commands.ts index 14fd44c9..852b3023 100644 --- a/src/lib/chat/chat-page-commands.ts +++ b/src/lib/chat/chat-page-commands.ts @@ -17,9 +17,10 @@ import { * - `/plan` flips the permission-mode chip between Plan only and Ask, through the same * command the chip calls. It never switches to Bypass, which needs the chip's confirm. * - * The message box can be open while a reply is still running: an ask_user question pauses the - * turn and hands the box back. `/compact` and `/plan` then say why they cannot run, as their - * buttons are disabled (the chip) or do nothing (Compact) until the reply ends. + * The message box can be open while a reply is still running: a question from the agent (its + * AskUserQuestion, #4) pauses the turn and hands the box back. `/compact` and `/plan` then say + * why they cannot run, as their buttons are disabled (the chip) or do nothing (Compact) until + * the reply ends. */ export function buildChatPageCommands(input: { conversationId: string diff --git a/src/lib/chat/chat.remote.ts b/src/lib/chat/chat.remote.ts index 0fb127d1..32e36d3b 100644 --- a/src/lib/chat/chat.remote.ts +++ b/src/lib/chat/chat.remote.ts @@ -116,7 +116,7 @@ export const getConversation = query(conversationIdSchema, async (conversationId findLiveChatRun(conversationId, user.id), ]) - // Surface the first un-decided ask_user entry so a hard refresh during a paused question + // Surface the first un-decided question so a hard refresh during a paused question // can resume — the stream path owns updates while connected; this is the resume seed. const undecided = (activeRun?.pendingQuestions ?? []).find( (entry): entry is PendingQuestionEntry => !!entry?.token && !entry.decidedAt, diff --git a/src/lib/chat/chat.ts b/src/lib/chat/chat.ts index be1c8dd9..7dcc07b0 100644 --- a/src/lib/chat/chat.ts +++ b/src/lib/chat/chat.ts @@ -65,7 +65,7 @@ marked.setOptions({ /* * Everything rendered here is model output — replies, thinking, subagent results, - * ask_user questions — and it goes straight into `{@html}` on a page that can call every + * the agent's questions — and it goes straight into `{@html}` on a page that can call every * remote function as the user. So it is sanitized at the renderer (see * `util/safe-markdown.ts` for the rules). This must be an object literal: `marked` only * honours own enumerable properties of `use({ renderer })`. @@ -300,40 +300,3 @@ export function getWebSearchPreview(toolName: string, rawResult: unknown): WebSe export function faviconUrl(hostname: string) { return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(hostname)}&sz=32` } - -/* ── Tool result caps ──────────────────────────────────────── */ - -export function trimToolResult(toolName: string, resultStr: string): string { - const limits: Record = { - web_search: 6000, - Read: 32000, - Bash: 16000, - browser_screenshot: Infinity, - run_subagent: 16000, - } - - const limit = limits[toolName] ?? 16000 - - if (resultStr.length <= limit) return resultStr - - try { - const parsed = JSON.parse(resultStr) - - if (toolName === 'web_search' && Array.isArray(parsed)) { - const trimmed = parsed.slice(0, 5).map((r: Record) => ({ - ...r, - snippet: typeof r.snippet === 'string' ? r.snippet.slice(0, 500) : r.snippet, - content: typeof r.content === 'string' ? r.content.slice(0, 500) : r.content, - })) - return JSON.stringify(trimmed) - } - - const s = JSON.stringify(parsed) - if (s.length > limit) { - return s.slice(0, limit) + `\n... [truncated from ${s.length} chars]` - } - return s - } catch { - return resultStr.slice(0, limit) + `\n... [truncated from ${resultStr.length} chars]` - } -} diff --git a/src/lib/chat/composer-commands.ts b/src/lib/chat/composer-commands.ts index 0b9c2d9b..2e0c7a6e 100644 --- a/src/lib/chat/composer-commands.ts +++ b/src/lib/chat/composer-commands.ts @@ -260,7 +260,8 @@ export function voiceCommand(input: { /** * Why a command that acts on the conversation cannot run now. A reply can still be running while - * the message box is open: an ask_user question pauses it and hands the box back to the user. + * the message box is open: a question from the agent (AskUserQuestion, #4) pauses it and hands the + * box back to the user. */ export const REPLY_RUNNING = 'A reply is still running. Wait for it to finish, or stop it, first.' diff --git a/src/lib/chat/creation-flow.ts b/src/lib/chat/creation-flow.ts index cada2d01..6508e8ab 100644 --- a/src/lib/chat/creation-flow.ts +++ b/src/lib/chat/creation-flow.ts @@ -12,7 +12,7 @@ type GuidedCreationInput = { function buildPrompt({ kind, context = {} }: GuidedCreationInput) { const commonRules = [ 'Help me create this using a cooperative execution flow.', - 'First ask clarifying questions one at a time if needed, using ask_user when options are helpful.', + 'First ask clarifying questions one at a time if needed, using AskUserQuestion when options are helpful.', 'Execute tool calls directly as needed once requirements are clear.', 'If user input is missing, ask for it and then continue execution.', 'Summarize what was created and any follow-up options.', diff --git a/src/lib/chat/message-bubble-helpers.ts b/src/lib/chat/message-bubble-helpers.ts index 4e3cc01b..a09bd45b 100644 --- a/src/lib/chat/message-bubble-helpers.ts +++ b/src/lib/chat/message-bubble-helpers.ts @@ -15,6 +15,8 @@ import type { ToolResultDetails } from '../engine/tool-result-details' import type { RunNotice } from '../engine/sdk-notices' import { readAskUserAnswers } from './ask-user-answers' +import { getAskUserAnswersFromTool, getAskUserQuestionsFromTool } from './tool-block-helpers' +import { answerKey } from '../engine/ask-user-question' export type SavedBlock = | { kind: 'text'; content: string } @@ -115,6 +117,33 @@ export function getAskUserAnswer(resultValue: unknown, header: string, headers: return trimmed.length > 0 ? trimmed : null } +/** + * A saved question block as the transcript shows it: each question, and the answer under it + * when there is one. Reads an AskUserQuestion block (#4) — answers on its `details`, keyed by + * question text — and a retired `ask_user` block, whose answers are in its result. + */ +export function savedAskUserExchanges(block: { + name: string + arguments: unknown + result: unknown + details?: unknown +}): Array<{ question: string; answer: string | null }> { + const toText = (value: unknown) => + value === undefined || value === null ? null : typeof value === 'string' ? value : JSON.stringify(value) + const like = { + name: block.name, + arguments: toText(block.arguments) ?? '', + result: toText(block.result), + details: block.details, + } + const questions = getAskUserQuestionsFromTool(like) + const answers = getAskUserAnswersFromTool(like) + return questions.map((question) => { + const answer = answers?.[answerKey(question)]?.trim() + return { question: question.question || question.header, answer: answer ? answer : null } + }) +} + /** Lowercase + collapse whitespace for the dedupe-text comparison. */ export function normalizeText(value: string): string { return value.toLowerCase().replace(/\s+/g, ' ').trim() diff --git a/src/lib/chat/run-controls.ts b/src/lib/chat/run-controls.ts index e276a41b..1de8c24a 100644 --- a/src/lib/chat/run-controls.ts +++ b/src/lib/chat/run-controls.ts @@ -67,7 +67,7 @@ export function approvalAnswerProblem(ok: boolean, status: number, body: unknown } /** - * What to tell the user when an `ask_user` answer was not recorded, or null when it was. + * What to tell the user when an answer to the agent's question was not recorded, or null when it was. * * `/ask-user` answers a token it cannot find with a 200 and `resolved: false`, and the page * read only the status: the modal closed as if the answer had been taken, and the answer diff --git a/src/lib/chat/stream-slots.server.ts b/src/lib/chat/stream-slots.server.ts index fd5eb65a..5fd6714b 100644 --- a/src/lib/chat/stream-slots.server.ts +++ b/src/lib/chat/stream-slots.server.ts @@ -242,22 +242,23 @@ export async function buildProjectContextSlot(input: { const ORCHESTRATOR_TOOL_POLICY = [ 'Tool usage policy:', - '- If the user asks you to ask questions, gather preferences with options, or confirm choices before continuing, you MUST call the ask_user tool.', - "- Do not only say you'll ask a question in plain text when ask_user is appropriate.", - '- Use concise questions with clear option labels, and allow freeform input when the request is open-ended.', - '- For ask_user: aim for ~3 prefilled answer options per question. Prefer asking more focused questions (split complex choices across multiple questions) rather than listing many options in one question.', + '- If the user asks you to ask questions, gather preferences with options, or confirm choices before continuing, you MUST call the AskUserQuestion tool.', + "- Do not only say you'll ask a question in plain text when AskUserQuestion is appropriate.", + '- Use concise questions with clear option labels. The user can always pick "Other" and type their own answer, so never add an "Other" option yourself.', + '- For AskUserQuestion: aim for ~3 options per question. Prefer asking more focused questions (split complex choices across multiple questions) rather than listing many options in one question. Set multiSelect when the choices are not mutually exclusive.', + '- When the options are things to compare by eye (layouts, snippets, configurations), give each one an HTML preview. The user sees it in a sandboxed pane, so keep it self-contained with inline styles.', '', ...SUBAGENT_RESULT_POLICY_LINES, ].join('\n') const AGENT_TOOL_POLICY = [ 'Tool usage policy:', - '- You cannot call ask_user directly in agent conversations.', + '- You cannot ask the user questions directly (AskUserQuestion) in agent conversations.', '- If you need user input, summarize missing information and return control to orchestrator for follow-up.', ].join('\n') /** - * The tool-usage policy slot. Orchestrator agents get the ask_user-encouraging + * The tool-usage policy slot. Orchestrator agents get the AskUserQuestion-encouraging * variant; sub-agents get the variant that tells them they can't ask the user * directly. Priority 90 — high enough to be near the top, below identity and * project context. diff --git a/src/lib/chat/streaming-blocks.ts b/src/lib/chat/streaming-blocks.ts index 2cf4efb0..39c68dd4 100644 --- a/src/lib/chat/streaming-blocks.ts +++ b/src/lib/chat/streaming-blocks.ts @@ -9,7 +9,13 @@ * stats, or building the persistence payload on stop / error). */ -import { parseJsonFallback } from '$lib/chat/tool-block-helpers' +import { getAskUserQuestionsFromTool, parseJsonFallback } from '$lib/chat/tool-block-helpers' +import { + ASK_USER_QUESTION_TOOL, + LEGACY_ASK_USER_TOOL, + isAskUserToolName, + type AskQuestion, +} from '../engine/ask-user-question' import type { ToolResultDetails } from '../engine/tool-result-details' import type { RunNotice } from '../engine/sdk-notices' @@ -693,15 +699,18 @@ export function applyToolResult( const finalStatus = payload.success ? ('completed' as const) : ('failed' as const) const resultText = payload.result ?? (payload.success ? 'Success' : 'Tool execution failed') /* - * #81 — an ask_user card is keyed by the host's answer token (the `ask_user` frame) and - * its result by the SDK's tool_use id, so the two never met: the card stayed open with its - * Submit button and an empty block was appended instead. The result belongs to the oldest - * card still waiting for one — a card whose id is still its token — and the card takes the - * SDK's id with it, so a replayed result finds it directly. + * #81 — a retired `ask_user` card was keyed by the host's answer token (the `ask_user` + * frame) and its result by the SDK's tool_use id, so the two never met: the card stayed + * open with its Submit button and an empty block was appended instead. The result belongs + * to the oldest card still waiting for one — a card whose id is still its token — and the + * card takes the SDK's id with it, so a replayed result finds it directly. + * + * An AskUserQuestion card (#4) is opened under the tool_use id itself, so its result finds + * it by id like any other call's. */ const askUserCard = - payload.name === 'ask_user' && !blocks.some((b) => b.kind === 'tool' && b.id === payload.id) - ? blocks.findIndex((b) => b.kind === 'tool' && b.name === 'ask_user' && !!b.token && b.id === b.token) + payload.name === LEGACY_ASK_USER_TOOL && !blocks.some((b) => b.kind === 'tool' && b.id === payload.id) + ? blocks.findIndex((b) => b.kind === 'tool' && b.name === LEGACY_ASK_USER_TOOL && !!b.token && b.id === b.token) : -1 const idx = askUserCard !== -1 ? askUserCard : blocks.findIndex((b) => b.kind === 'tool' && b.id === payload.id) if (idx === -1) { @@ -728,10 +737,11 @@ export function applyToolResult( } } const existing = blocks[idx] - // An answered ask_user card is already marked completed by `applyAskUserAnswered`. + // An answered question card is already marked completed by `applyAskUserAnswered`. const unexpected = askUserCard === -1 && existing.kind === 'tool' && + !isAskUserToolName(existing.name) && existing.status !== 'executing' && existing.status !== 'approved' return { @@ -753,9 +763,9 @@ export function applyToolResult( } /** - * The server recorded the user's answers to an ask_user card (#81): show it answered now, + * The server recorded the user's answers to a question card (#81): show it answered now, * rather than with its Submit button still live until the call's `tool_result` arrives. The - * card keeps its token as its id, so that result still finds it (`applyToolResult`). + * card keeps its id, so that result still finds it (`applyToolResult`). */ export function applyAskUserAnswered( blocks: StreamingBlock[], @@ -763,16 +773,56 @@ export function applyAskUserAnswered( answers: Record, ): StreamingBlock[] { return blocks.map((b) => - b.kind === 'tool' && b.name === 'ask_user' && b.token === token + b.kind === 'tool' && isAskUserToolName(b.name) && b.token === token ? { ...b, status: 'completed' as const, result: JSON.stringify({ answers }) } : b, ) } +/** The question the chat page's composer and modal answer: one card's token and questions. */ +export type PendingAskUser = { token: string; questions: AskQuestion[] } + +/** The answer token of the question card a call's result landed on, if it is one. */ +export function askUserTokenFor(blocks: StreamingBlock[], id: string): string | null { + const block = blocks.find((b) => b.kind === 'tool' && b.id === id) + return block?.kind === 'tool' && isAskUserToolName(block.name) ? (block.token ?? null) : null +} + +/** + * Which question the page treats as pending once the card for `settledToken` is answered or + * has its call's result (#4). + * + * The CLI runs AskUserQuestion calls concurrently (`isConcurrencySafe`), so one assistant + * message can put two cards on screen, each answered under its own token. The page keeps one + * of them as *the* pending question — the one the composer's free-text reply and the modal + * answer. Settling another card leaves it alone. Settling it hands the role to the newest + * card still waiting, so a composer reply still has a question to go to; with none, nothing + * is pending. An unknown `settledToken` (null) just re-reads which card is still waiting. + */ +export function settlePendingAskUser( + blocks: StreamingBlock[], + current: PendingAskUser | null, + settledToken: string | null, +): PendingAskUser | null { + if (current && settledToken !== null && current.token !== settledToken) return current + for (let i = blocks.length - 1; i >= 0; i -= 1) { + const block = blocks[i] + if (block.kind !== 'tool' || !isAskUserToolName(block.name) || !block.token) continue + if (block.token === settledToken) continue + if (block.status !== 'pending' && block.status !== 'approved' && block.status !== 'executing') continue + const questions = getAskUserQuestionsFromTool(block) + if (questions.length > 0) return { token: block.token, questions } + } + return null +} + /** * `ask_user` event — append a synthetic executing tool block carrying the * questions so the AskUserCard can render inline. Skips the append when a tool * block with the same id already exists (idempotent re-emit). + * + * The frame names the tool: `AskUserQuestion`, with the SDK's tool_use id as its id (#4), or + * `ask_user` from a run that predates it. A frame that names none is the former. */ export function applyAskUser( blocks: StreamingBlock[], @@ -788,7 +838,7 @@ export function applyAskUser( { kind: 'tool' as const, id: payload.id, - name: payload.name ?? 'ask_user', + name: payload.name ?? ASK_USER_QUESTION_TOOL, arguments: askUserArgs, status: 'executing' as const, expanded: true, diff --git a/src/lib/chat/tool-block-helpers.ts b/src/lib/chat/tool-block-helpers.ts index efcdf825..1bec1660 100644 --- a/src/lib/chat/tool-block-helpers.ts +++ b/src/lib/chat/tool-block-helpers.ts @@ -5,32 +5,46 @@ * `result` coming out). These helpers shape that raw payload into the * structured forms the UI cards expect, with permissive fallbacks so a * partially-formed block still renders something useful. + * + * Relative imports only: `./message-bubble-helpers` imports this, and specs load both in the + * plain Playwright loader, where the SvelteKit alias is not guaranteed to resolve. */ -export type AskUserOption = { - label: string - description?: string - recommended?: boolean -} +// Re-export so call sites that already import `parseJsonFallback` keep working; +// the shared implementation lives in `$lib/util/json` (parseJsonRecord — also +// guards against non-object JSON values like null or arrays). +import { parseJsonRecord as parseJsonFallback } from '../util/json' +import { readAskUserAnswers } from './ask-user-answers' +import { + LEGACY_ASK_USER_TOOL, + answerKey, + isAskUserToolName, + readAskQuestions, + type AskOption, + type AskQuestion, +} from '../engine/ask-user-question' +export { parseJsonFallback } -export type AskUserQuestion = { - header: string - question: string - options: AskUserOption[] - allowFreeformInput?: boolean -} +/** + * A question card's question: the SDK's AskUserQuestion (#4) or a retired `ask_user` one. + * Both read into one shape — see `$lib/engine/ask-user-question`. + */ +export type AskUserOption = AskOption +export type AskUserQuestion = AskQuestion +export { isAskUserToolName } type ToolBlockLike = { + /** `AskUserQuestion`, or `ask_user` for a block from before #4. Absent reads as the latter. */ + name?: string arguments: string result?: string | null + /** The call's distilled result; an AskUserQuestion's carries its answers. */ + details?: unknown } -// Re-export so call sites that already import `parseJsonFallback` keep working; -// the shared implementation lives in `$lib/util/json` (parseJsonRecord — also -// guards against non-object JSON values like null or arrays). -import { parseJsonRecord as parseJsonFallback } from '$lib/util/json' -import { readAskUserAnswers } from './ask-user-answers' -export { parseJsonFallback } +function isLegacyBlock(block: ToolBlockLike): boolean { + return block.name === undefined || block.name === LEGACY_ASK_USER_TOOL +} export function getAskUserQuestionsFromTool(block: ToolBlockLike): AskUserQuestion[] { const args = parseJsonFallback(block.arguments) @@ -38,34 +52,31 @@ export function getAskUserQuestionsFromTool(block: ToolBlockLike): AskUserQuesti const fromArgs = Array.isArray(args.questions) ? args.questions : [] const fromResult = Array.isArray(result.questions) ? result.questions : [] const source = fromArgs.length > 0 ? fromArgs : fromResult + return readAskQuestions(source, { legacy: isLegacyBlock(block) }) +} - return source - .map((entry) => { - const row = (entry ?? {}) as Record - const header = typeof row.header === 'string' ? row.header : '' - const question = typeof row.question === 'string' ? row.question : header - const options = Array.isArray(row.options) - ? (row.options as Array>) - .map((opt) => ({ - label: typeof opt.label === 'string' ? opt.label : '', - description: typeof opt.description === 'string' ? opt.description : undefined, - recommended: typeof opt.recommended === 'boolean' ? opt.recommended : undefined, - })) - .filter((opt) => opt.label.length > 0) - : [] - const allowFreeformInput = - typeof row.allowFreeformInput === 'boolean' ? row.allowFreeformInput : true - return { header, question, options, allowFreeformInput } - }) - .filter((row) => row.question.trim().length > 0) +/** The answers on an AskUserQuestion block's distilled result, keyed by question text. */ +function detailsAnswers(details: unknown): Record | null { + const record = details && typeof details === 'object' ? (details as { kind?: unknown; answers?: unknown }) : null + if (record?.kind !== 'ask_user_question' || !record.answers || typeof record.answers !== 'object') return null + const out: Record = {} + for (const [key, value] of Object.entries(record.answers as Record)) { + if (typeof value === 'string' && value.trim()) out[key] = value + } + return Object.keys(out).length > 0 ? out : null } /** - * The answers recorded on an ask_user block's result, or null while it has none. Reads the - * host's `Header: answer` text as well as a JSON `{ answers }` object — see `./ask-user-answers`. + * The answers recorded on a question block, keyed like its questions (`answerKey`), or null + * while it has none. An AskUserQuestion block carries them on its `details` once the call's + * result is in; before that — the moment the server records them — as a JSON `{ answers }` + * result. A retired `ask_user` block also reads the host's `Header: answer` text; see + * `./ask-user-answers`. */ export function getAskUserAnswersFromTool(block: ToolBlockLike): Record | null { + const fromDetails = detailsAnswers(block.details) + if (fromDetails) return fromDetails if (!block.result) return null - const headers = getAskUserQuestionsFromTool(block).map((question) => question.header) - return readAskUserAnswers(block.result, headers) + const keys = getAskUserQuestionsFromTool(block).map((question) => answerKey(question)) + return readAskUserAnswers(block.result, keys) } diff --git a/src/lib/engine/agent-definitions.ts b/src/lib/engine/agent-definitions.ts index 9401f1c8..48fcabdb 100644 --- a/src/lib/engine/agent-definitions.ts +++ b/src/lib/engine/agent-definitions.ts @@ -18,9 +18,10 @@ * `idle` and `active` both available — lives in `$lib/agents/agent-status`, which the Pause * button, the automation gate and this filter all read. * - * **`ask_user` is disallowed for every subagent.** The old loop refused it for - * non-orchestrators — a child has no stream to ask down — and that rule has to survive the - * port or a delegated agent will hang waiting for an answer nobody is being shown. + * **`AskUserQuestion` is disallowed for every subagent.** The old loop refused `ask_user` for + * non-orchestrators — a child has no stream to ask down — and that rule survived the port to + * the SDK's own question tool (#4). The engine refuses a child's question in `canUseTool` as + * well, for the SDK's built-in agents, which no definition here reaches. * * **The model is inherited unless the row asks for a Claude model.** A run against the * gateway sets `ANTHROPIC_MODEL` for the whole process, so a subagent naming a different @@ -31,6 +32,7 @@ import { isAgentPaused } from '$lib/agents/agent-status' import { BUILTIN_TOOL_SET } from './builtin-tools' +import { ASK_USER_QUESTION_TOOL } from './ask-user-question' import { isSubscriptionModel, normalizeModelId } from './model-backend' import { OWN_MCP_SERVER } from './permission-mode' @@ -73,9 +75,9 @@ export function qualifyAgentTools(allowedTools: readonly string[]): string[] { /** Tools no subagent may call, whatever its own allow-list says. */ export const SUBAGENT_DISALLOWED_TOOLS: readonly string[] = [ - // A child has no stream to ask down. The old loop refused this for non-orchestrators; - // without it a delegated agent hangs on a question nobody is shown. - 'mcp__agentstudio__ask_user', + // A child has no stream to ask down. The old loop refused its `ask_user` for + // non-orchestrators; the SDK's question tool is refused the same way. + ASK_USER_QUESTION_TOOL, ] /** diff --git a/src/lib/engine/ask-user-question.ts b/src/lib/engine/ask-user-question.ts new file mode 100644 index 00000000..7d331fef --- /dev/null +++ b/src/lib/engine/ask-user-question.ts @@ -0,0 +1,418 @@ +/** + * The Agent SDK's native AskUserQuestion (#4): the questions as data, how the host answers + * one, and how an option's preview is rendered without trusting it. + * + * AgentStudio used to register its own `ask_user` tool on the in-process MCP server and + * special-case it in three places. The SDK ships a better one — a preview per option, + * multi-select, and a free-text "Other" the CLI adds itself — and it reaches the host the + * way every built-in call does: through `canUseTool`. The host answers it there, by + * returning the call's input with an `answers` map added. No tool of ours runs at all. + * + * Read off the installed SDK (`@anthropic-ai/claude-agent-sdk` 0.3.278, bundled CLI + * 2.1.278), not assumed: + * + * - `sdk-tools.d.ts` `AskUserQuestionInput`: 1–4 questions, each a `question`, a short + * `header` (the CLI calls it a chip, max 12 characters), 2–4 options of + * `{ label, description, preview? }` and `multiSelect`. `answers` is typed "User answers + * collected by the permission component", keyed by question text. The CLI's `call()` + * reads exactly that field back and echoes it on `tool_use_result` + * (`AskUserQuestionOutput.answers`, "multi-select answers are comma-separated"). + * - The CLI's own tool prompt says a recommended option goes first with "(Recommended)" at + * the end of its label, and that "Other" is never an option: "Users will always be able + * to select Other". + * - In `previewFormat: 'html'` the CLI rejects a preview that is a whole document or holds + * `