From 7afe21c3db6ad3e4d05487908e8289e077fdb18e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 16:52:52 +0000 Subject: [PATCH] [EPIC-X][LOU-X9] Built-in ask_question tool pauses durably until a human answers createAgent({ askQuestion: true }) (or askQuestionTool()) gives the agent ask_question({ question, options?, allowFreeText? }). A call pauses through the approval mechanism, so it is durable like any approval. The pending record, the approval.requested event and the React pendingApproval carry kind: 'question' and the question; agent.approvals.answer({ id, answer }) continues the run with { answer, option? }, declining yields a 'rejected' tool error, the approve callback may return a string answer, and the hook gets answer(text). An approved tool sees the decision's note as ctx.approval.note. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016bY29ibQgv3MDdjAbUT4YR --- CHANGELOG.md | 1 + README.md | 1 + docs/api-overview.md | 6 +- docs/approvals.md | 47 ++++++++ docs/react.md | 8 +- docs/streaming.md | 2 +- docs/tools.md | 1 + llms-full.txt | 65 ++++++++++- src/createAgent.ts | 9 +- src/createAgentApprovals.ts | 43 ++++++-- src/execution/ApprovalGate.ts | 45 +++++++- src/execution/agentEvents.ts | 5 + src/execution/agentRun.ts | 8 +- src/execution/resume.ts | 29 +++-- src/execution/toolRunContext.ts | 3 +- src/react/reducer.test.ts | 15 +++ src/react/reducer.ts | 13 ++- src/react/useLoushyAgent.test.ts | 19 ++++ src/react/useLoushyAgent.ts | 6 +- src/tools/built-in/askQuestion.test.ts | 143 +++++++++++++++++++++++++ src/tools/built-in/askQuestion.ts | 80 ++++++++++++++ src/tools/built-in/index.ts | 1 + src/types/tool.ts | 6 ++ 23 files changed, 521 insertions(+), 35 deletions(-) create mode 100644 src/tools/built-in/askQuestion.test.ts create mode 100644 src/tools/built-in/askQuestion.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e7a77118..437599635 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] - 2026-09-28 ### Added +- Built-in `ask_question` tool (LOU-X9): `createAgent({ askQuestion: true })` (off by default) or `askQuestionTool()` gives the agent `ask_question({ question, options?, allowFreeText? })`. A call pauses the run through the approval mechanism, so it waits durably like any approval (sessions, `memoryStore()` / `SqliteStore`, a restart between question and answer). The pending record gains optional `kind: 'question'` and `question: { text, options?, allowFreeText? }` (new types `ApprovalKind`, `ApprovalQuestion`; `describeApproval()` and `ASK_QUESTION_TOOL_NAME` are exported), and so do the `approval.requested` event and the React hook's `pendingApproval`. The new `agent.approvals.answer({ id, answer })` (the same as `resolve({ id, approved: true, note: answer })`) continues the run with `{ answer, option? }` as the tool result (`option`: index of the matching option); `approved: false` gives the model a `kind: 'rejected'` error saying the user declined to answer. `useLoushyAgent()` gets `answer(text)`. The `approve` callback may now return a string (`ApproveToolCall` returns `boolean | string`), which approves with that note, so code can answer questions. A tool run after an approval sees the decision's note as `ctx.approval.note` (new optional `ToolExecutionContext.approval`). See docs/approvals.md "Asking the user a question". - Fork and replay (LOU-D44): `AgentExecutor.fork({ sessionId, fromStep, newSessionId?, checkpointStore, patch? })` takes the newest entry of `fromStep` in the session's checkpoint history, applies `patch` (`messages(messages)`, `businessState`, `toolResult: { toolCallId, result }` replaced in place so the transcript stays provider-valid, `appendInput` queued as a user message) and saves it as the `'in-progress'` checkpoint of `newSessionId` (default `.fork-`), leaving the original session untouched; it returns `{ sessionId, step, checkpoint }`, and `execute({ sessionId, checkpointStore, input: [] })` continues the fork. A missing step throws the new `LOUSHY_CHECKPOINT_NOT_FOUND` code. `createAgent()` agents gain `agent.fork(sessionId, { fromStep, newSessionId?, patch? })` over `store.checkpoints` (resume with `agent.resume()`). `compareTrajectories(a, b)` compares two checkpoints or transcripts step by step: each run's steps (text, tool calls and results), the first diverging step and the `loushy eval --drift` diff. New types `ForkOptions`, `ForkPatch`, `ForkResult`, `TrajectoryComparison`, `TrajectoryStep` and `DriftEntry`. See docs/durable-execution.md#fork-and-replay. - Multimodal message parts (LOU-V11): `Message.content` is now `string | ContentPart[]`, where a `ContentPart` is `{ type: 'text', text }`, `{ type: 'image', image, mimeType? }` (an `http(s)` URL, a `data:` URL or a `Uint8Array`) or `{ type: 'file', data, mimeType, filename? }` (new types `ContentPart`, `TextContentPart`, `ImageContentPart`, `FileContentPart`). The built-in providers map the parts of `user` messages to the `ai` v4 user-content parts: images go to the model with all four (the `ai` SDK downloads an image URL first for Anthropic and Ollama); file parts become a `[file () not sent]` text note with a one-time `console.warn`, since the pinned peers (`@ai-sdk/*` 0.0.x, `ollama-ai-provider`) cannot send files (a subclass sets `acceptsFileParts = true` to send them). `system`, `assistant` and `tool` messages are sent as their text. The new `textOf(message)` returns a message's text (the string, or its text parts joined), and is what `estimateTokens()` (each image or file part counts 1,000 tokens), compaction, `recordReplay()` fingerprints (cassette messages gain an optional `attachments` list: each image or file as its URL or a digest of its bytes), `mockModel()`, `MockLLMProvider`, OpenTelemetry content attributes and the React reducer (`ui.send` takes parts too) now read. `FileSessionStore` saves `Uint8Array`s as `{ "$bytes": "" }` and loads them back. Pass multimodal messages as `AgentExecutor.execute({ input: Message[] })`; `agent.send()` / `session.send()` taking parts is LOU-V12. See docs/providers.md#multimodal-input. - Declarative permission policies (LOU-X2): `permissions?: PermissionRule[]` on `createAgent()`, `ExecuteOptions` and `resumeAfterApproval()` options. A rule is `{ tool: string | string[] | RegExp | '*', when?: (args, { toolName, toolCallId, sessionId }) => boolean | Promise, action: 'allow' | 'deny' | 'ask', reason? }`; rules are checked in order for each tool call (after argument validation and `preToolCall` hooks, before `needsApproval`) and the first match decides: `allow` runs the call without approval even when `needsApproval` would ask, `deny` gives the model a tool error with the new `kind: 'denied'` (`ToolDeniedError`, plus `reason`) and streams `tool.error`, `ask` pauses for approval (or asks `approve`). No match keeps today's behaviour. New helpers `allow(tools)`, `deny(tools, reason?)`, `ask(tools)` and types `PermissionRule`, `PermissionAction`, `PermissionToolMatcher`, `PermissionContext`, `PermissionDecision`, `PermissionDecisionEntry`, `PermissionOptions`. Audit log: `onPermissionDecision(entry)` gets `{ toolName, toolCallId, decision: 'allow' | 'deny' | 'ask' | 'default', rule?: { index, reason? }, args?, at }` per call (`args` left out under `redactContent`), and streams get the same entry as the new typed `permission.decision` event (`PermissionDecisionEvent`; additive, the event schema version stays 1). Neither is produced unless `permissions` or `onPermissionDecision` is set. Sub-agents inherit the lead's rules (checked before their own) and its `onPermissionDecision`. See docs/approvals.md#permission-policies. diff --git a/README.md b/README.md index fca1aae52..216591346 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ agent can be an `agent.yaml` spec served with `npx loushy dev agent.yaml` - **Tools**: `defineTool()` with a zod `input`; arguments are typed, validated, and run in parallel. [Tools](docs/tools.md) - **Approvals and permission policies**: `needsApproval` pauses a run; `agent.approvals.resolve()` continues it, or `approve` decides in code. `permissions` rules allow, deny or ask per tool before that, with an audit log. [Approvals](docs/approvals.md) +- **Ask the user a question**: `createAgent({ askQuestion: true })` adds the built-in `ask_question` tool; the run pauses durably until `agent.approvals.answer()`. [Asking the user a question](docs/approvals.md#asking-the-user-a-question) - **Sessions**: `agent.session()` keeps a multi-turn conversation in memory, files or SQLite. [Sessions](docs/sessions.md) - **Structured output**: `output: zodSchema` makes the final reply a typed, validated `result.object`, with one repair step. [Structured output](docs/structured-output.md) - **Streaming**: `agent.stream()` and `session.stream()` yield typed, versioned JSON events ready for SSE. [Streaming](docs/streaming.md) diff --git a/docs/api-overview.md b/docs/api-overview.md index 49fa9e482..fad0bf1dd 100644 --- a/docs/api-overview.md +++ b/docs/api-overview.md @@ -81,9 +81,11 @@ and an `approvalId`. `agent.approvals.list()` returns the pending calls and `agent.approvals.resolve({ id, approved, note? })` runs or rejects the call and resolves with the continued run's result (continuing the session it paused in). Pauses are kept in a per-agent `InMemoryApprovalStore` unless you pass -`approvalStore` (e.g. `SqliteStore.approvals`) or a `store`; `approve: (call) => boolean` +`approvalStore` (e.g. `SqliteStore.approvals`) or a `store`; `approve: (call) => boolean | string` decides each call in code without pausing (`stream()` still ends at the -pause). See [Approvals](./approvals.md). +pause). With `askQuestion: true` the agent can ask the user a question +(`kind: 'question'`), answered with `agent.approvals.answer({ id, answer })`. +See [Approvals](./approvals.md). ### Structured output diff --git a/docs/approvals.md b/docs/approvals.md index fdf0f2429..e71b58b92 100644 --- a/docs/approvals.md +++ b/docs/approvals.md @@ -169,6 +169,53 @@ const trusted = createAgent({ }); ``` +## Asking the user a question + +`createAgent({ askQuestion: true })` gives the agent the built-in +`ask_question` tool (off by default; `askQuestionTool()` returns the same tool +to pass in `tools` yourself). Its input is +`{ question: string; options?: string[]; allowFreeText?: boolean }`. A call +pauses the run through the approval mechanism, so it waits exactly like an +approval: in sessions, in durable stores and across a restart. + +- The pending record has `kind: 'question'` and + `question: { text, options?, allowFreeText? }`, in `agent.approvals.list()`, + in the `approve` callback and on the `approval.requested` event, so a UI can + render a question instead of an approve button. +- `agent.approvals.answer({ id, answer })` (the same as + `resolve({ id, approved: true, note: answer })`) continues the run. The + model gets `{ answer, option? }`, where `option` is the index of the matching + entry of `options` (case-insensitive). With `allowFreeText: false`, an answer + outside the options reaches the model as a tool error. +- `resolve({ id, approved: false, note? })` declines: the model gets a + `kind: 'rejected'` tool error saying the user declined to answer. +- An `approve` callback may return a string to answer in code, which is handy + in tests and scripted agents. + +```ts +import { createAgent } from '@loushy/build-ai-agent'; + +const agent = createAgent({ prompt: 'Plan the trip with the user.', provider, askQuestion: true }); + +const paused = await agent.send('Book me a weekend away'); +const [pending] = await agent.approvals.list(); +if (paused.approvalId && pending?.kind === 'question') { + console.log(pending.question?.text, pending.question?.options); + const result = await agent.approvals.answer({ id: paused.approvalId, answer: 'Lisbon' }); + console.log(result.text); +} + +// Scripted: answer every question in code. +const scripted = createAgent({ + provider, + askQuestion: true, + approve: (request) => (request.kind === 'question' ? 'Lisbon' : false), +}); +``` + +A permission rule that `allow`s `ask_question` skips the pause, so the call +fails with "No answer"; leave the tool to its default. + ## `AgentExecutor` and `resumeAfterApproval()` With `AgentExecutor.execute()` directly, pass an `approvalStore`. On a gated diff --git a/docs/react.md b/docs/react.md index c5272d5e1..e00693afb 100644 --- a/docs/react.md +++ b/docs/react.md @@ -67,13 +67,14 @@ on the server. | `messages` | `UIMessage[]`: `{ id, role: 'user' \| 'assistant', text, toolCalls }`. Each `send()` adds a user message and an assistant message that fills in as `text.delta` and tool events arrive. | | `toolCalls[i]` | `{ id, name, args, status, result?, error? }`, `status` being `'running'`, `'awaiting-approval'`, `'done'`, `'error'` or `'rejected'`. | | `status` | `'idle'`, `'streaming'`, `'awaiting-approval'` or `'error'`. | -| `pendingApproval` | `{ id, toolCallId, toolName, args }` of the tool call the run paused on, else `null`. | +| `pendingApproval` | `{ id, toolCallId, toolName, args }` of the tool call the run paused on, else `null`. For an `ask_question` call it also has `kind: 'question'` and `question: { text, options?, allowFreeText? }`. | | `error` | `{ name, message }` of the last `error` event or a failed request, else `null`. | | `usage` | Token usage of the last finished run (from `run.done`), else `null`. | | `lastEvent` | The last event received, for anything the projection does not cover. | | `send(input)` | Starts a turn. If a turn is still running, it is aborted first. | | `stop()` | Aborts the turn in flight through its `AbortSignal`; `status` goes back to `'idle'`. | | `approve(note?)`, `reject(note?)` | Decide `pendingApproval` (see below). | +| `answer(text)` | Answers a question (`pendingApproval.kind === 'question'`); the same as `approve(text)`. `reject()` declines it. | Unmounting the component aborts the turn in flight. Events of a [sub-agent](./sub-agents.md)'s run (they carry `subagent`) do not change @@ -97,6 +98,11 @@ When a tool with `needsApproval` is called, the run stops with Show `pendingApproval` and resolve it through your own API, then `send()` the next turn. +An agent created with `askQuestion: true` can ask the user something (see +[Asking the user a question](./approvals.md#asking-the-user-a-question)). The +pause arrives the same way, with `pendingApproval.kind === 'question'`: show +`pendingApproval.question.text` and its `options`, and call `answer(text)`. + ## The server side A Node endpoint for the remote mode, with the approvals route: diff --git a/docs/streaming.md b/docs/streaming.md index 834b58f91..023b4fc22 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -106,7 +106,7 @@ The event types and their extra fields: | `tool.start` | `toolCallId: string`, `toolName: string`, `args: Record` | A tool call starts. `args` are the model's arguments parsed from JSON (`{}` when they are not valid JSON). | | `tool.done` | `toolCallId`, `toolName`, `result: unknown`, `durationMs: number` | A tool call returned. `result` is the value as it would be JSON-encoded (`undefined` becomes `null`, a `Date` becomes a string). `durationMs` counts from its `tool.start`. | | `tool.error` | `toolCallId`, `toolName`, `error: { name: string, message: string }`, `durationMs: number` | A tool call failed: it threw, its arguments did not match its schema (`name: 'ToolArgumentsValidationError'`), or the tool does not exist. The model gets the error as the call's result and the run continues. | -| `approval.requested` | `approvalId: string`, `toolCallId`, `toolName`, `args: Record` | A tool call needs a human decision. The run then stops; resume it with `agent.approvals.resolve()` or `resumeAfterApproval()` (see [Approvals](./approvals.md)). | +| `approval.requested` | `approvalId: string`, `toolCallId`, `toolName`, `args: Record`, `kind?: 'question'`, `question?: { text, options?, allowFreeText? }` | A tool call needs a human decision. The run then stops; resume it with `agent.approvals.resolve()` or `resumeAfterApproval()` (see [Approvals](./approvals.md)). An `ask_question` call carries `kind: 'question'` and its `question`; answer it with `agent.approvals.answer()` (see [Asking the user a question](./approvals.md#asking-the-user-a-question)). | | `permission.decision` | `toolCallId`, `toolName`, `decision: 'allow' \| 'deny' \| 'ask' \| 'default'`, `rule?: { index: number, reason?: string }`, `args?: Record`, `at: string` | How a tool call's [permission rules](./approvals.md#permission-policies) decided it: after its `tool.start`, before the call's own `tool.done` / `tool.error` / `approval.requested`. Only when the run sets `permissions` or `onPermissionDecision`; `args` is left out under `redactContent`. | | `step.done` | `step: number`, `finishReason: string`, `usage?: { promptTokens, completionTokens, totalTokens }` | A step ends. `finishReason` is the model's (`'stop'`, `'tool_calls'`, `'length'`, ...), or `'awaiting-approval'`, `'aborted'` or `'error'` when the step ended that way (a run that runs out of `maxSteps` still wanting to continue ends with `run.done` `'max-steps'`). `usage` is this step's model call, absent when the call produced no response. | | `error` | `error: { name: string, message: string }` | An error. If it ends the run, `run.done` with `finishReason: 'error'` follows. A provider error retried under `surfaceRetryableProviderErrors` is followed by further steps instead. | diff --git a/docs/tools.md b/docs/tools.md index 909204b9b..596e301d1 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -123,6 +123,7 @@ its kind by carrying a `toolErrorKind` property. An error extending | `httpTool`, `createHttpTool(options)` | HTTP requests, with SSRF protection (every resolved address is checked before connecting). | | `currentDateTool`, `dayNameTool` | Current date/time (ISO, UTC) and day of the week. | | `createTodoTools()` | `todo_write` / `todo_read` so an agent can plan multi-step work; see [Todo tools](./api-overview.md#todo-tools). | +| `askQuestionTool()`, `createAgent({ askQuestion: true })` | `ask_question`: the agent asks the user something and the run pauses until `agent.approvals.answer()`; see [Asking the user a question](./approvals.md#asking-the-user-a-question). | | `createFsTools()`, `createShellTool()` | File system and shell tools for coding agents; see [Workspace tools](./workspace-tools.md). | | `createEmailTool()`, `createSlackTool()`, `createGitHubTools()`, `createJiraTools()` | Integrations that need credentials, so they are built with options. | | `createAgent({ mcpServers })`, `connectMcp(servers)` | Every tool of MCP servers given as config (stdio `command` or HTTP `url`), named `__`; see [Connect MCP servers](./configuration.md#connect-mcp-servers-mcpservers-connectmcp). | diff --git a/llms-full.txt b/llms-full.txt index 709d3db4b..0d40b0fe2 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -70,6 +70,7 @@ agent can be an `agent.yaml` spec served with `npx loushy dev agent.yaml` - **Tools**: `defineTool()` with a zod `input`; arguments are typed, validated, and run in parallel. [Tools](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/tools.md) - **Approvals and permission policies**: `needsApproval` pauses a run; `agent.approvals.resolve()` continues it, or `approve` decides in code. `permissions` rules allow, deny or ask per tool before that, with an audit log. [Approvals](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/approvals.md) +- **Ask the user a question**: `createAgent({ askQuestion: true })` adds the built-in `ask_question` tool; the run pauses durably until `agent.approvals.answer()`. [Asking the user a question](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/approvals.md#asking-the-user-a-question) - **Sessions**: `agent.session()` keeps a multi-turn conversation in memory, files or SQLite. [Sessions](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/sessions.md) - **Structured output**: `output: zodSchema` makes the final reply a typed, validated `result.object`, with one repair step. [Structured output](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/structured-output.md) - **Streaming**: `agent.stream()` and `session.stream()` yield typed, versioned JSON events ready for SSE. [Streaming](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/streaming.md) @@ -759,9 +760,11 @@ and an `approvalId`. `agent.approvals.list()` returns the pending calls and `agent.approvals.resolve({ id, approved, note? })` runs or rejects the call and resolves with the continued run's result (continuing the session it paused in). Pauses are kept in a per-agent `InMemoryApprovalStore` unless you pass -`approvalStore` (e.g. `SqliteStore.approvals`) or a `store`; `approve: (call) => boolean` +`approvalStore` (e.g. `SqliteStore.approvals`) or a `store`; `approve: (call) => boolean | string` decides each call in code without pausing (`stream()` still ends at the -pause). See [Approvals](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/approvals.md). +pause). With `askQuestion: true` the agent can ask the user a question +(`kind: 'question'`), answered with `agent.approvals.answer({ id, answer })`. +See [Approvals](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/approvals.md). ### Structured output @@ -1984,6 +1987,53 @@ const trusted = createAgent({ }); ``` +## Asking the user a question + +`createAgent({ askQuestion: true })` gives the agent the built-in +`ask_question` tool (off by default; `askQuestionTool()` returns the same tool +to pass in `tools` yourself). Its input is +`{ question: string; options?: string[]; allowFreeText?: boolean }`. A call +pauses the run through the approval mechanism, so it waits exactly like an +approval: in sessions, in durable stores and across a restart. + +- The pending record has `kind: 'question'` and + `question: { text, options?, allowFreeText? }`, in `agent.approvals.list()`, + in the `approve` callback and on the `approval.requested` event, so a UI can + render a question instead of an approve button. +- `agent.approvals.answer({ id, answer })` (the same as + `resolve({ id, approved: true, note: answer })`) continues the run. The + model gets `{ answer, option? }`, where `option` is the index of the matching + entry of `options` (case-insensitive). With `allowFreeText: false`, an answer + outside the options reaches the model as a tool error. +- `resolve({ id, approved: false, note? })` declines: the model gets a + `kind: 'rejected'` tool error saying the user declined to answer. +- An `approve` callback may return a string to answer in code, which is handy + in tests and scripted agents. + +```ts +import { createAgent } from '@loushy/build-ai-agent'; + +const agent = createAgent({ prompt: 'Plan the trip with the user.', provider, askQuestion: true }); + +const paused = await agent.send('Book me a weekend away'); +const [pending] = await agent.approvals.list(); +if (paused.approvalId && pending?.kind === 'question') { + console.log(pending.question?.text, pending.question?.options); + const result = await agent.approvals.answer({ id: paused.approvalId, answer: 'Lisbon' }); + console.log(result.text); +} + +// Scripted: answer every question in code. +const scripted = createAgent({ + provider, + askQuestion: true, + approve: (request) => (request.kind === 'question' ? 'Lisbon' : false), +}); +``` + +A permission rule that `allow`s `ask_question` skips the pause, so the call +fails with "No answer"; leave the tool to its default. + ## `AgentExecutor` and `resumeAfterApproval()` With `AgentExecutor.execute()` directly, pass an `approvalStore`. On a gated @@ -4522,13 +4572,14 @@ on the server. | `messages` | `UIMessage[]`: `{ id, role: 'user' \| 'assistant', text, toolCalls }`. Each `send()` adds a user message and an assistant message that fills in as `text.delta` and tool events arrive. | | `toolCalls[i]` | `{ id, name, args, status, result?, error? }`, `status` being `'running'`, `'awaiting-approval'`, `'done'`, `'error'` or `'rejected'`. | | `status` | `'idle'`, `'streaming'`, `'awaiting-approval'` or `'error'`. | -| `pendingApproval` | `{ id, toolCallId, toolName, args }` of the tool call the run paused on, else `null`. | +| `pendingApproval` | `{ id, toolCallId, toolName, args }` of the tool call the run paused on, else `null`. For an `ask_question` call it also has `kind: 'question'` and `question: { text, options?, allowFreeText? }`. | | `error` | `{ name, message }` of the last `error` event or a failed request, else `null`. | | `usage` | Token usage of the last finished run (from `run.done`), else `null`. | | `lastEvent` | The last event received, for anything the projection does not cover. | | `send(input)` | Starts a turn. If a turn is still running, it is aborted first. | | `stop()` | Aborts the turn in flight through its `AbortSignal`; `status` goes back to `'idle'`. | | `approve(note?)`, `reject(note?)` | Decide `pendingApproval` (see below). | +| `answer(text)` | Answers a question (`pendingApproval.kind === 'question'`); the same as `approve(text)`. `reject()` declines it. | Unmounting the component aborts the turn in flight. Events of a [sub-agent](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/sub-agents.md)'s run (they carry `subagent`) do not change @@ -4552,6 +4603,11 @@ When a tool with `needsApproval` is called, the run stops with Show `pendingApproval` and resolve it through your own API, then `send()` the next turn. +An agent created with `askQuestion: true` can ask the user something (see +[Asking the user a question](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/approvals.md#asking-the-user-a-question)). The +pause arrives the same way, with `pendingApproval.kind === 'question'`: show +`pendingApproval.question.text` and its `options`, and call `answer(text)`. + ## The server side A Node endpoint for the remote mode, with the approvals route: @@ -5119,7 +5175,7 @@ The event types and their extra fields: | `tool.start` | `toolCallId: string`, `toolName: string`, `args: Record` | A tool call starts. `args` are the model's arguments parsed from JSON (`{}` when they are not valid JSON). | | `tool.done` | `toolCallId`, `toolName`, `result: unknown`, `durationMs: number` | A tool call returned. `result` is the value as it would be JSON-encoded (`undefined` becomes `null`, a `Date` becomes a string). `durationMs` counts from its `tool.start`. | | `tool.error` | `toolCallId`, `toolName`, `error: { name: string, message: string }`, `durationMs: number` | A tool call failed: it threw, its arguments did not match its schema (`name: 'ToolArgumentsValidationError'`), or the tool does not exist. The model gets the error as the call's result and the run continues. | -| `approval.requested` | `approvalId: string`, `toolCallId`, `toolName`, `args: Record` | A tool call needs a human decision. The run then stops; resume it with `agent.approvals.resolve()` or `resumeAfterApproval()` (see [Approvals](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/approvals.md)). | +| `approval.requested` | `approvalId: string`, `toolCallId`, `toolName`, `args: Record`, `kind?: 'question'`, `question?: { text, options?, allowFreeText? }` | A tool call needs a human decision. The run then stops; resume it with `agent.approvals.resolve()` or `resumeAfterApproval()` (see [Approvals](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/approvals.md)). An `ask_question` call carries `kind: 'question'` and its `question`; answer it with `agent.approvals.answer()` (see [Asking the user a question](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/approvals.md#asking-the-user-a-question)). | | `permission.decision` | `toolCallId`, `toolName`, `decision: 'allow' \| 'deny' \| 'ask' \| 'default'`, `rule?: { index: number, reason?: string }`, `args?: Record`, `at: string` | How a tool call's [permission rules](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/approvals.md#permission-policies) decided it: after its `tool.start`, before the call's own `tool.done` / `tool.error` / `approval.requested`. Only when the run sets `permissions` or `onPermissionDecision`; `args` is left out under `redactContent`. | | `step.done` | `step: number`, `finishReason: string`, `usage?: { promptTokens, completionTokens, totalTokens }` | A step ends. `finishReason` is the model's (`'stop'`, `'tool_calls'`, `'length'`, ...), or `'awaiting-approval'`, `'aborted'` or `'error'` when the step ended that way (a run that runs out of `maxSteps` still wanting to continue ends with `run.done` `'max-steps'`). `usage` is this step's model call, absent when the call produced no response. | | `error` | `error: { name: string, message: string }` | An error. If it ends the run, `run.done` with `finishReason: 'error'` follows. A provider error retried under `surfaceRetryableProviderErrors` is followed by further steps instead. | @@ -6226,6 +6282,7 @@ its kind by carrying a `toolErrorKind` property. An error extending | `httpTool`, `createHttpTool(options)` | HTTP requests, with SSRF protection (every resolved address is checked before connecting). | | `currentDateTool`, `dayNameTool` | Current date/time (ISO, UTC) and day of the week. | | `createTodoTools()` | `todo_write` / `todo_read` so an agent can plan multi-step work; see [Todo tools](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/api-overview.md#todo-tools). | +| `askQuestionTool()`, `createAgent({ askQuestion: true })` | `ask_question`: the agent asks the user something and the run pauses until `agent.approvals.answer()`; see [Asking the user a question](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/approvals.md#asking-the-user-a-question). | | `createFsTools()`, `createShellTool()` | File system and shell tools for coding agents; see [Workspace tools](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/workspace-tools.md). | | `createEmailTool()`, `createSlackTool()`, `createGitHubTools()`, `createJiraTools()` | Integrations that need credentials, so they are built with options. | | `createAgent({ mcpServers })`, `connectMcp(servers)` | Every tool of MCP servers given as config (stdio `command` or HTTP `url`), named `__`; see [Connect MCP servers](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/configuration.md#connect-mcp-servers-mcpservers-connectmcp). | diff --git a/src/createAgent.ts b/src/createAgent.ts index 4e7066aa6..f122d1439 100644 --- a/src/createAgent.ts +++ b/src/createAgent.ts @@ -24,6 +24,7 @@ import { ToolDescriptor } from './types'; import { modelFromEnv, resolveProviderSpec } from './providers/providerSpec'; import { withFallback, withRetry, type WithRetryOptions } from './providers/resilience'; import type { DefinedTool } from './tools/defineTool'; +import { withAskQuestion } from './tools/built-in/askQuestion'; import { ToolConcurrency, assertToolConcurrency } from './execution/toolBatch'; import type { Skill } from './skills/defineSkill'; import type { Message } from './providers/llm'; @@ -184,6 +185,12 @@ export interface CreateAgentBase ex * ``` */ approve?: ApproveToolCall; + /** + * Adds the built-in `ask_question` tool (LOU-X9): the agent can ask the + * user a question, and the run pauses (like an approval, `kind: 'question'`) + * until `agent.approvals.answer({ id, answer })`. Off by default. + */ + askQuestion?: boolean; /** * Retries of a failed model call (LOU-V7.2), with `withRetry()`: rate * limits, timeouts, network errors and 5xx responses, with exponential @@ -451,7 +458,7 @@ export function createAgent( const provider = resolveModelSource(config); const hasMcp = Object.keys(config.mcpServers ?? {}).length > 0; - const { toolRegistry, toolsConfig } = registerTools(config.tools ?? {}, hasMcp); + const { toolRegistry, toolsConfig } = registerTools(withAskQuestion(config.tools, config.askQuestion) ?? {}, hasMcp); // LOU-Z4: MCP tools join the registry and the agent's tools once connected. const mcp = agentMcp(config.mcpServers, (tools) => { for (const [name, descriptor] of Object.entries(tools)) { diff --git a/src/createAgentApprovals.ts b/src/createAgentApprovals.ts index 22759dd69..805fddaa3 100644 --- a/src/createAgentApprovals.ts +++ b/src/createAgentApprovals.ts @@ -6,7 +6,7 @@ * each pause at once by resuming the run with the callback's answer. */ -import type { ApprovalDecision, ApprovalStore, PendingApproval } from './execution/ApprovalGate'; +import { describeApproval, type ApprovalDecision, type ApprovalStore, type PendingApproval } from './execution/ApprovalGate'; import type { ExecutionResult } from './execution/AgentExecutor'; import type { AgentRun } from './execution/agentRun'; import type { CheckpointStore } from './execution/checkpoint'; @@ -16,13 +16,16 @@ import { AgentSession, type SessionOptions, type SessionRunner, type SessionStre /** * Decides a tool call that needs approval without pausing the run: `true` * runs the tool, `false` gives the model a rejection as the tool's result. + * A string approves with that string as the note - for an `ask_question` + * call (`request.kind === 'question'`), it is the answer (LOU-X9). * * @example * ```ts * const approve: ApproveToolCall = ({ toolName, args }) => toolName !== 'send_email' || args.to === 'me@example.com'; + * const answer: ApproveToolCall = (request) => (request.kind === 'question' ? 'Lisbon' : true); * ``` */ -export type ApproveToolCall = (request: PendingApproval) => boolean | Promise; +export type ApproveToolCall = (request: PendingApproval) => boolean | string | Promise; /** `agent.approvals`: the tool calls a `createAgent()` agent is paused on, and how to decide them. */ export interface AgentApprovals { @@ -41,6 +44,17 @@ export interface AgentApprovals { * ``` */ resolve(decision: ApprovalDecision, options?: { signal?: AbortSignal }): Promise; + /** + * Answers a paused `ask_question` call (LOU-X9): the same as + * `resolve({ id, approved: true, note: answer })`. The model gets + * `{ answer, option? }` as the tool's result. + * + * @example + * ```ts + * const result = await agent.approvals.answer({ id: paused.approvalId!, answer: 'Lisbon' }); + * ``` + */ + answer(reply: { id: string; answer: string }, options?: { signal?: AbortSignal }): Promise; } /** @@ -67,7 +81,9 @@ export function createAgentApprovals(options: { store: ApprovalStore; approve?: const pending = new Map(); const sessions = new Map(); const store: ApprovalStore = { - async save(request, snapshot) { + async save(raw, snapshot) { + // LOU-X9: an `ask_question` call is recorded as `kind: 'question'`. + const request = describeApproval(raw); await options.store.save(request, snapshot); pending.set(request.id, request); }, @@ -88,7 +104,9 @@ export function createAgentApprovals(options: { store: ApprovalStore; approve?: for (;;) { const request = current.approvalId ? pending.get(current.approvalId) : undefined; if (!approve || current.finishReason !== 'awaiting-approval' || !request) return current; - current = await resume(store, { id: request.id, approved: await approve(request) }, signal, checkpointStore); + const verdict = await approve(request); + const decision = typeof verdict === 'string' ? { id: request.id, approved: true, note: verdict } : { id: request.id, approved: verdict }; + current = await resume(store, decision, signal, checkpointStore); } } @@ -113,15 +131,18 @@ export function createAgentApprovals(options: { store: ApprovalStore; approve?: }; } + function resolve(decision: ApprovalDecision, { signal }: { signal?: AbortSignal } = {}): Promise { + const session = sessions.get(decision.id); + sessions.delete(decision.id); + const next = async (checkpointStore?: CheckpointStore) => + inSession(session, await settle(await resume(store, decision, signal, checkpointStore), signal, checkpointStore)); + return session ? session.resolveWith(next) : next(); + } + const approvals: AgentApprovals = { list: async () => [...pending.values()], - resolve(decision, { signal } = {}) { - const session = sessions.get(decision.id); - sessions.delete(decision.id); - const next = async (checkpointStore?: CheckpointStore) => - inSession(session, await settle(await resume(store, decision, signal, checkpointStore), signal, checkpointStore)); - return session ? session.resolveWith(next) : next(); - }, + resolve, + answer: ({ id, answer }, resolveOptions) => resolve({ id, approved: true, note: answer }, resolveOptions), }; return { diff --git a/src/execution/ApprovalGate.ts b/src/execution/ApprovalGate.ts index 422e97745..c586afd57 100644 --- a/src/execution/ApprovalGate.ts +++ b/src/execution/ApprovalGate.ts @@ -24,10 +24,53 @@ export interface PendingApproval { * Absent for the top-level agent's own tool calls. */ subagentPath?: string[]; + /** + * LOU-X9: what the pause asks the human for. `'question'` for a call of the + * built-in `ask_question` tool (see `question`); absent for a tool call + * waiting on approval. + */ + kind?: ApprovalKind; + /** LOU-X9: the question to show, when `kind` is `'question'`. */ + question?: ApprovalQuestion; +} + +/** LOU-X9: what a pending approval asks for. Absent on a record means `'tool'`. */ +export type ApprovalKind = 'tool' | 'question'; + +/** LOU-X9: the question an `ask_question` call puts to the user. */ +export interface ApprovalQuestion { + text: string; + /** Choices to offer, in order; the tool result's `option` is an index into them. */ + options?: string[]; + /** With `options`: `false` means the answer must be one of them. */ + allowFreeText?: boolean; +} + +/** LOU-X9: the name of the built-in question tool (`askQuestionTool()`). */ +export const ASK_QUESTION_TOOL_NAME = 'ask_question'; + +/** + * LOU-X9: `pending` with `kind: 'question'` and its `question` filled in when + * it is an `ask_question` call; any other record is returned as is. + */ +export function describeApproval(pending: PendingApproval): PendingApproval { + if (pending.kind !== undefined || pending.toolName !== ASK_QUESTION_TOOL_NAME) return pending; + const { question, options, allowFreeText } = pending.args; + return { + ...pending, + kind: 'question', + question: { + text: String(question ?? ''), + ...(Array.isArray(options) && { options: options.map(String) }), + ...(typeof allowFreeText === 'boolean' && { allowFreeText }), + }, + }; } /** - * A human decision resolving a PendingApproval. + * A human decision resolving a PendingApproval. For a question (LOU-X9), + * `approved: true` with the answer as `note` answers it; `approved: false` + * declines to answer. */ export interface ApprovalDecision { id: string; diff --git a/src/execution/agentEvents.ts b/src/execution/agentEvents.ts index e20a307ef..f277656f9 100644 --- a/src/execution/agentEvents.ts +++ b/src/execution/agentEvents.ts @@ -11,6 +11,7 @@ import type { ExecutionFinishReason } from './AgentExecutor'; import type { SubagentInfo } from './hooks'; import type { CompactedProviderErrorCategory } from './errors'; import type { PermissionDecisionEntry } from './permissions'; +import type { ApprovalKind, ApprovalQuestion } from './ApprovalGate'; /** * Version of the {@link AgentEvent} schema, carried on every event as `v`. @@ -128,6 +129,10 @@ export interface ApprovalRequestedEvent extends AgentEventBase<'approval.request toolCallId: string; toolName: string; args: Record; + /** LOU-X9: `'question'` when an `ask_question` call waits for the user's answer; absent for a tool approval. */ + kind?: ApprovalKind; + /** LOU-X9: the question's text and options, when `kind` is `'question'`. */ + question?: ApprovalQuestion; } /** diff --git a/src/execution/agentRun.ts b/src/execution/agentRun.ts index d0807e3ac..788c0a19d 100644 --- a/src/execution/agentRun.ts +++ b/src/execution/agentRun.ts @@ -17,7 +17,7 @@ import { newId } from '../utils/id'; import type { GenerateOptions, GenerateResult, LLMProvider } from '../providers'; import type { ExecuteOptions, ExecutionEvent, ExecutionResult } from './AgentExecutor'; -import type { PendingApproval } from './ApprovalGate'; +import { describeApproval, type PendingApproval } from './ApprovalGate'; import type { HookEventPayload, SubagentInfo } from './hooks'; import { AGENT_EVENT_SCHEMA_VERSION, @@ -329,14 +329,18 @@ class AgentRunImpl implements AgentRun { subagent ); }, - approvalRequested: (pending) => { + approvalRequested: (paused) => { if (subagent) return; + const pending = describeApproval(paused); this.emit({ type: 'approval.requested', approvalId: pending.id, toolCallId: pending.toolCallId, toolName: pending.toolName, args: toJsonValue(pending.args) as Record, + // LOU-X9: an `ask_question` call carries its question. + ...(pending.kind && { kind: pending.kind }), + ...(pending.question && { question: pending.question }), }); }, permissionDecision: (entry) => diff --git a/src/execution/resume.ts b/src/execution/resume.ts index 9356b84c4..2acfdf16a 100644 --- a/src/execution/resume.ts +++ b/src/execution/resume.ts @@ -6,10 +6,11 @@ import { LLMProvider, Message, ToolCall } from '../providers'; import { ToolRegistry } from '../tools'; import { getToolExecute } from '../tools/toolContract'; -import { ToolDescriptor } from '../types'; +import type { ToolDescriptor, ToolExecutionContext } from '../types'; import { ApprovalDecision, ApprovalStore, + describeApproval, ExecutionSnapshot, PendingApproval, } from './ApprovalGate'; @@ -168,8 +169,12 @@ async function decidedToolMessage( pending: PendingApproval ): Promise<{ message: Message } | { paused: ExecutionResult }> { const { snapshot, messages, executeOptions } = ctx; - const runApproved = (call: PendingApproval, registry: ToolRegistry, scope: ToolCallScope): Promise => - runApprovedToolCall(call, snapshot, messages, registry, executeOptions, scope); + const runApproved = ( + call: PendingApproval, + registry: ToolRegistry, + scope: ToolCallScope, + approval?: ToolExecutionContext['approval'] + ): Promise => runApprovedToolCall(call, snapshot, messages, registry, executeOptions, scope, approval); if (snapshot.subagent) { return resumeSubagentCall(ctx, snapshot.subagent, runApproved); } @@ -179,7 +184,11 @@ async function decidedToolMessage( pending, toolErrorResult({ toolName: pending.toolName, - error: 'Tool execution was rejected by the reviewer', + // LOU-X9: declining an `ask_question` call is not a tool rejection. + error: + describeApproval(pending).kind === 'question' + ? 'The user declined to answer the question' + : 'Tool execution was rejected by the reviewer', kind: 'rejected', details: { note: ctx.decision.note }, }), @@ -194,7 +203,8 @@ async function decidedToolMessage( onDelegatedUsage: (child) => mergeDelegatedUsage(ctx.usage, child), execute: ctx.execute, }; - return { message: await runApproved(pending, ctx.toolRegistry, scope) }; + // LOU-X9: the tool sees the decision's note (an `ask_question` answer) as `ctx.approval`. + return { message: await runApproved(pending, ctx.toolRegistry, scope, { note: ctx.decision.note }) }; } /** @@ -325,7 +335,8 @@ async function runApprovedToolCall( messages: Message[], toolRegistry: ToolRegistry, executeOptions: ResumeExecuteOptions, - scope?: ToolCallScope + scope?: ToolCallScope, + approval?: ToolExecutionContext['approval'] ): Promise { const toolDesc = toolRegistry.get(pending.toolName); // A `requiresSandbox` tool may have no `tool.execute` implementation at @@ -372,7 +383,7 @@ async function runApprovedToolCall( await hooks.runPreToolCall(hookCtx); } - const { result, toolError, errorResult } = await executeApprovedTool(pending, toolDesc, hookArgs, executeOptions, messages, scope); + const { result, toolError, errorResult } = await executeApprovedTool(pending, toolDesc, hookArgs, executeOptions, { messages, approval }, scope); // Fires (with the settled result/error) regardless of how the tool // settled - matching AgentHook.postToolCall's documented contract @@ -408,7 +419,7 @@ async function executeApprovedTool( toolDesc: ToolDescriptor, args: Record, executeOptions: ResumeExecuteOptions, - messages: Message[], + { messages, approval }: { messages: Message[]; approval?: ToolExecutionContext['approval'] }, scope?: ToolCallScope ): Promise<{ result: unknown; toolError?: string; errorResult?: ToolErrorResult }> { try { @@ -425,7 +436,7 @@ async function executeApprovedTool( args, sandbox, executeOptions.signal, - { toolCallId: pending.toolCallId, messages }, + { toolCallId: pending.toolCallId, messages, approval }, scope ), }; diff --git a/src/execution/toolRunContext.ts b/src/execution/toolRunContext.ts index 09f8268d6..0602da634 100644 --- a/src/execution/toolRunContext.ts +++ b/src/execution/toolRunContext.ts @@ -20,7 +20,7 @@ import { bindToolCallScope, type ToolCallScope } from './subagentRuntime'; export type ToolRunContext = Partial>; /** What {@link buildToolRunContext} builds the context from. */ -export interface ToolRunInput extends Pick { +export interface ToolRunInput extends Pick { /** The run's transcript. The tool gets the part before the model turn that made this call. */ messages?: readonly Message[]; /** The run's cancellation signal; the tool gets it as `abortSignal`. */ @@ -54,6 +54,7 @@ export function buildToolRunContext(input: ToolRunInput): ToolExecutionContext { abortSignal: input.signal, onDelegatedUsage: input.onDelegatedUsage, ...(input.sessionId !== undefined && { sessionId: input.sessionId }), + ...(input.approval && { approval: input.approval }), }; bindToolCallScope(ctx, input.scope); return ctx; diff --git a/src/react/reducer.test.ts b/src/react/reducer.test.ts index e61812664..4efd547d2 100644 --- a/src/react/reducer.test.ts +++ b/src/react/reducer.test.ts @@ -97,6 +97,21 @@ describe('reduceAgentEvents (LOU-D15)', () => { expect(rejected.messages[1].toolCalls[0].status).toBe('rejected'); }); + it("an ask_question pause exposes kind: 'question' and the question (LOU-X9)", () => { + const question = { text: 'Which city?', options: ['Porto', 'Lisbon'], allowFreeText: false }; + const args = { question: 'Which city?', options: ['Porto', 'Lisbon'], allowFreeText: false }; + const paused = reduce( + send, + ...events( + { type: 'approval.requested', approvalId: 'q1', toolCallId: 'c1', toolName: 'ask_question', args, kind: 'question', question }, + { type: 'run.done', finishReason: 'awaiting-approval', text: '' } + ) + ); + expect(paused.status).toBe('awaiting-approval'); + expect(paused.pendingApproval).toEqual({ id: 'q1', toolCallId: 'c1', toolName: 'ask_question', args, kind: 'question', question }); + expect(paused.messages[1].toolCalls[0]).toMatchObject({ name: 'ask_question', status: 'awaiting-approval' }); + }); + it('a resumed run that pauses again exposes the next approval', () => { const next = { id: 'ap2', toolCallId: 'c2', toolName: 'pay', args: {} }; const state = reduce(send, { type: 'ui.resumed', outcome: { text: '', finishReason: 'awaiting-approval', approval: next } }); diff --git a/src/react/reducer.ts b/src/react/reducer.ts index c2a5f53e8..24115ea51 100644 --- a/src/react/reducer.ts +++ b/src/react/reducer.ts @@ -6,6 +6,7 @@ */ import type { AgentEvent, AgentEventError, AgentEventUsage } from '../execution/agentEvents'; +import type { ApprovalKind, ApprovalQuestion } from '../execution/ApprovalGate'; import type { ContentPart } from '../providers/llm'; import { textOf } from '../providers/content'; @@ -43,6 +44,10 @@ export interface UIPendingApproval { toolCallId: string; toolName: string; args: Record; + /** LOU-X9: `'question'` when the agent asked the user something (`ask_question`); answer it with `answer(text)`. */ + kind?: ApprovalKind; + /** LOU-X9: the question's text and options, when `kind` is `'question'`. */ + question?: ApprovalQuestion; } /** How a run continued after an approval decision (built from `agent.approvals.resolve()`'s result). */ @@ -108,6 +113,12 @@ function patchTool(messages: UIMessage[], id: string, patch: Partial }); } +/** The paused call of an `approval.requested` event, with its question when it has one (LOU-X9). */ +function pendingOf(event: Extract): UIPendingApproval { + const { approvalId: id, toolCallId, toolName, args, kind, question } = event; + return { id, toolCallId, toolName, args, ...(kind && { kind }), ...(question && { question }) }; +} + function pause(state: AgentUIState, approval: UIPendingApproval): AgentUIState { const { toolCallId: id, toolName: name, args } = approval; const messages = patchTool(state.messages, id, { status: 'awaiting-approval' }, { id, name, args, status: 'running' }); @@ -169,7 +180,7 @@ export function reduceAgentEvents(state: AgentUIState, event: AgentEvent | Agent case 'tool.error': return { ...next, messages: patchTool(state.messages, event.toolCallId, { status: 'error', error: event.error }) }; case 'approval.requested': - return pause(next, { id: event.approvalId, toolCallId: event.toolCallId, toolName: event.toolName, args: event.args }); + return pause(next, pendingOf(event)); case 'error': return { ...next, error: event.error }; case 'run.done': diff --git a/src/react/useLoushyAgent.test.ts b/src/react/useLoushyAgent.test.ts index 4898a6825..6981f3a8f 100644 --- a/src/react/useLoushyAgent.test.ts +++ b/src/react/useLoushyAgent.test.ts @@ -91,6 +91,25 @@ describe('useLoushyAgent in process (LOU-D15)', () => { expect(hook.messages[1]).toMatchObject({ text: 'Email sent.', toolCalls: [{ name: 'send_email', status: 'done' }] }); }); + it("answer(text) answers an ask_question pause (LOU-X9)", async () => { + const ask = { toolCalls: [{ name: 'ask_question', args: { question: 'Which city?', options: ['Porto', 'Lisbon'] }, id: 'call_q' }] }; + const model = mockModel([ask, 'Booking Lisbon.']); + mount({ agent: createAgent({ provider: model, askQuestion: true }) }); + + await act(() => hook.send('Book a trip')); + expect(hook.pendingApproval).toMatchObject({ + kind: 'question', + question: { text: 'Which city?', options: ['Porto', 'Lisbon'] }, + }); + + await act(() => hook.answer('Lisbon')); + + expect(hook.status).toBe('idle'); + expect(hook.messages[1]).toMatchObject({ text: 'Booking Lisbon.', toolCalls: [{ name: 'ask_question', status: 'done' }] }); + const result = model.lastCall?.messages.find((m) => m.role === 'tool' && m.toolCallId === 'call_q'); + expect(JSON.parse(result?.content as string)).toEqual({ answer: 'Lisbon', option: 1 }); + }); + it('reject() gives the model a rejection and marks the call rejected', async () => { const { agent, execute } = emailAgent(callEmail, 'OK, not sent.'); mount({ agent }); diff --git a/src/react/useLoushyAgent.ts b/src/react/useLoushyAgent.ts index 1017e4517..bbed4d78f 100644 --- a/src/react/useLoushyAgent.ts +++ b/src/react/useLoushyAgent.ts @@ -48,6 +48,8 @@ export interface UseLoushyAgentResult extends AgentUIState { stop(): void; approve(note?: string): Promise; reject(note?: string): Promise; + /** LOU-X9: answers the question in `pendingApproval` (`kind: 'question'`); same as `approve(text)`. */ + answer(text: string): Promise; } type Emit = (action: AgentEvent | AgentUIAction) => void; @@ -151,5 +153,7 @@ export function useLoushyAgent(source: LoushyAgentSource, options: UseLoushyAgen const approve = useCallback((note?: string) => decide(true, note), [decide]); const reject = useCallback((note?: string) => decide(false, note), [decide]); - return { ...state, send, stop, approve, reject }; + const answer = useCallback((text: string) => decide(true, text), [decide]); + + return { ...state, send, stop, approve, reject, answer }; } diff --git a/src/tools/built-in/askQuestion.test.ts b/src/tools/built-in/askQuestion.test.ts new file mode 100644 index 000000000..cfa7f91da --- /dev/null +++ b/src/tools/built-in/askQuestion.test.ts @@ -0,0 +1,143 @@ +/** + * LOU-X9: the built-in `ask_question` tool pauses the run through the + * approval mechanism until a human answers (or declines), durably. + */ +import { describe, it, expect, vi } from 'vitest'; +import { createAgent } from '../../createAgent'; +import { memoryStore, type AgentStore } from '../../storage/agentStore'; +import { SqliteStore } from '../../storage/sqlite'; +import { mockModel, type MockTurn } from '../../testing'; +import type { ApproveToolCall } from '../../createAgentApprovals'; +import type { AgentEvent } from '../../execution/agentEvents'; +import type { Message } from '../../providers'; +import { askQuestionTool } from './askQuestion'; + +const asking = (args: Record): MockTurn => ({ toolCalls: [{ name: 'ask_question', args, id: 'call_q' }] }); +const whichCity = asking({ question: 'Which city?', options: ['Porto', 'Lisbon'] }); + +/** The `ask_question` result the model saw on its last call. */ +function seen(model: ReturnType): { content: unknown; isError?: boolean } { + const message = (model.lastCall?.messages as Message[]).find((m) => m.role === 'tool' && m.toolCallId === 'call_q'); + return { content: JSON.parse(message?.content as string), isError: message?.isError }; +} + +describe('ask_question (LOU-X9)', () => { + it("pauses with kind: 'question'; answer() continues and the model sees the answer", async () => { + const model = mockModel([asking({ question: 'Where to?' }), 'Booking Lisbon.']); + const agent = createAgent({ provider: model, askQuestion: true }); + + const paused = await agent.send('Book a trip'); + + expect(paused.finishReason).toBe('awaiting-approval'); + expect(await agent.approvals.list()).toEqual([ + expect.objectContaining({ id: paused.approvalId, toolName: 'ask_question', kind: 'question', question: { text: 'Where to?' } }), + ]); + const result = await agent.approvals.answer({ id: paused.approvalId!, answer: 'Lisbon' }); + + expect(result.text).toBe('Booking Lisbon.'); + expect(seen(model)).toEqual({ content: { answer: 'Lisbon' }, isError: undefined }); + }); + + it('resolve({ approved: true, note }) answers too, and an answer matching an option gives its index', async () => { + const model = mockModel([whichCity, 'Lisbon it is.']); + const agent = createAgent({ provider: model, askQuestion: true }); + + const paused = await agent.send('Book a trip'); + await agent.approvals.resolve({ id: paused.approvalId!, approved: true, note: 'lisbon' }); + + expect(seen(model).content).toEqual({ answer: 'lisbon', option: 1 }); + }); + + it('declining gives the model a structured rejection', async () => { + const model = mockModel([whichCity, 'No problem.']); + const agent = createAgent({ provider: model, askQuestion: true }); + + const paused = await agent.send('Book a trip'); + const result = await agent.approvals.resolve({ id: paused.approvalId!, approved: false, note: 'Not now' }); + + expect(result.text).toBe('No problem.'); + expect(seen(model)).toEqual({ + content: { + error: 'ToolRejectedError', + toolName: 'ask_question', + message: 'The user declined to answer the question', + kind: 'rejected', + note: 'Not now', + }, + isError: true, + }); + }); + + it('with allowFreeText: false, an answer outside the options reaches the model as a tool error', async () => { + const model = mockModel([asking({ question: 'Which city?', options: ['Porto'], allowFreeText: false }), 'Hm.']); + const agent = createAgent({ provider: model, askQuestion: true }); + + const paused = await agent.send('Book a trip'); + await agent.approvals.answer({ id: paused.approvalId!, answer: 'Faro' }); + + expect(seen(model)).toMatchObject({ content: { kind: 'execution', message: 'The answer must be one of: Porto' }, isError: true }); + }); + + it('an approve callback receives the question and may answer with a string', async () => { + const model = mockModel([whichCity, 'Porto it is.']); + const approve = vi.fn, string>(() => 'Porto'); + const agent = createAgent({ provider: model, askQuestion: true, approve }); + + const result = await agent.send('Book a trip'); + + expect(result.text).toBe('Porto it is.'); + expect(approve).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'question', question: { text: 'Which city?', options: ['Porto', 'Lisbon'] } }) + ); + expect(seen(model).content).toEqual({ answer: 'Porto', option: 0 }); + }); + + it('approval.requested carries the question; inside a session the answer continues it', async () => { + const model = mockModel([whichCity, 'Lisbon.', 'You picked Lisbon.']); + const agent = createAgent({ provider: model, askQuestion: true }); + const session = agent.session({ id: 'trip' }); + const events: AgentEvent[] = []; + + for await (const event of session.stream('Book a trip')) events.push(event); + const requested = events.find((event) => event.type === 'approval.requested'); + expect(requested).toMatchObject({ kind: 'question', question: { text: 'Which city?', options: ['Porto', 'Lisbon'] } }); + + const answered = await agent.approvals.answer({ id: (requested as { approvalId: string }).approvalId, answer: 'Lisbon' }); + expect(answered.text).toBe('Lisbon.'); + expect((await session.send('Which city did I pick?')).text).toBe('You picked Lisbon.'); + expect(model.lastCall?.messages.some((m) => m.role === 'tool' && m.toolCallId === 'call_q')).toBe(true); + }); + + it('is only registered with askQuestion: true; run without an answer it fails', async () => { + const model = mockModel(['Hi.']); + await createAgent({ provider: model }).send('Hi'); + expect(model.lastCall?.tools?.map((tool) => tool.name) ?? []).not.toContain('ask_question'); + + const ctx = { toolCallId: 'c1', messages: [] }; + await expect(askQuestionTool().execute({ question: 'Where?' }, ctx)).rejects.toThrow(/No answer/); + }); +}); + +const stores: Array<[string, () => AgentStore & { close?: () => void }]> = [ + ['memoryStore()', memoryStore], + ["SqliteStore(':memory:')", () => new SqliteStore(':memory:')], +]; + +describe.each(stores)('ask_question with createAgent({ store: %s }) (LOU-X9)', (_name, makeStore) => { + it('a restart between the question and the answer: a fresh agent answers and finishes the run', async () => { + const store = makeStore(); + const before = createAgent({ provider: mockModel([whichCity]), askQuestion: true, store }); + const paused = await before.send('Book a trip', { sessionId: 'trip-1' }); + expect(paused.finishReason).toBe('awaiting-approval'); + + const model = mockModel(['Booked Lisbon.']); + const agent = createAgent({ provider: model, askQuestion: true, store }); + await expect(agent.resume('trip-1')).rejects.toMatchObject({ name: 'SessionAwaitingApprovalError' }); + const result = await agent.approvals.answer({ id: paused.approvalId!, answer: 'Lisbon' }); + + expect(result.text).toBe('Booked Lisbon.'); + expect(seen(model).content).toEqual({ answer: 'Lisbon', option: 1 }); + expect(await store.checkpoints!.load('trip-1')).toMatchObject({ status: 'finished' }); + store.close?.(); + }); +}); diff --git a/src/tools/built-in/askQuestion.ts b/src/tools/built-in/askQuestion.ts new file mode 100644 index 000000000..d612dff1a --- /dev/null +++ b/src/tools/built-in/askQuestion.ts @@ -0,0 +1,80 @@ +/** + * Built-in `ask_question` tool (LOU-X9). + * + * Lets the agent ask the user a question and wait for the answer. The call + * pauses the run through the approval mechanism (`needsApproval: true`), so + * the wait is durable like any approval: the pending record has + * `kind: 'question'`, and `agent.approvals.answer({ id, answer })` (or + * `resolve({ id, approved: true, note: answer })`) continues the run with + * `{ answer, option? }` as the tool result. + */ +import { z } from 'zod'; +import { defineTool, type DefinedTool } from '../defineTool'; +import { ASK_QUESTION_TOOL_NAME } from '../../execution/ApprovalGate'; +import type { ToolDescriptor } from '../../types'; + +const askQuestionInput = z.object({ + question: z.string().trim().min(1).describe('The question to ask, in one or two sentences.'), + options: z + .array(z.string().trim().min(1)) + .min(1) + .optional() + .describe('Choices to offer when the answer is one of a few.'), + allowFreeText: z + .boolean() + .optional() + .describe('With options: whether the user may answer something else. Defaults to true.'), +}); + +/** The arguments of an `ask_question` call. */ +export type AskQuestionInput = z.output; + +/** What `ask_question` returns to the model once the user answered. */ +export interface AskQuestionResult { + answer: string; + /** Index into `options` when the answer is one of them (case-insensitive). */ + option?: number; +} + +/** + * The built-in `ask_question` tool: the run pauses until a human answers. + * Register it with `createAgent({ askQuestion: true })`, or pass it in + * `tools`. Answer with `agent.approvals.answer({ id, answer })`; an `approve` + * callback may answer by returning a string. + * + * @example + * ```ts + * const agent = createAgent({ prompt: 'Plan the trip.', provider, tools: [askQuestionTool()] }); + * ``` + */ +export function askQuestionTool(): DefinedTool { + return defineTool({ + name: ASK_QUESTION_TOOL_NAME, + description: + 'Ask the user a question and wait for the answer. Use it only for information or a decision that only the user ' + + 'can give; offer `options` when the answer is one of a few choices.', + input: askQuestionInput, + needsApproval: true, + execute({ options, allowFreeText }, ctx): AskQuestionResult { + const answer = ctx.approval?.note?.trim(); + if (!answer) { + throw new Error('No answer: ask_question returns only after a person answers it (agent.approvals.answer()).'); + } + const option = options?.findIndex((choice) => choice.toLowerCase() === answer.toLowerCase()) ?? -1; + if (option >= 0) return { answer, option }; + if (options && allowFreeText === false) { + throw new Error(`The answer must be one of: ${options.join(', ')}`); + } + return { answer }; + }, + }); +} + +type AgentTools = readonly DefinedTool[] | Record; + +/** `tools` plus `ask_question` when `enabled` (`createAgent({ askQuestion: true })`). */ +export function withAskQuestion(tools: AgentTools | undefined, enabled: boolean | undefined): AgentTools | undefined { + if (!enabled) return tools; + if (tools === undefined || Array.isArray(tools)) return [...((tools as readonly DefinedTool[]) ?? []), askQuestionTool()]; + return { ...tools, [ASK_QUESTION_TOOL_NAME]: askQuestionTool() }; +} diff --git a/src/tools/built-in/index.ts b/src/tools/built-in/index.ts index 7a3cdb836..5c383baef 100644 --- a/src/tools/built-in/index.ts +++ b/src/tools/built-in/index.ts @@ -11,3 +11,4 @@ export * from './jira'; export * from './github'; export * from './slack'; export * from './todo'; +export { askQuestionTool, type AskQuestionInput, type AskQuestionResult } from './askQuestion'; diff --git a/src/types/tool.ts b/src/types/tool.ts index 90a017b63..638d9ce3a 100644 --- a/src/types/tool.ts +++ b/src/types/tool.ts @@ -54,6 +54,12 @@ export interface ToolExecutionContext { sessionId?: string; /** Called by the delegate tool with a finished child run's usage, so the parent run adds it to its totals (LOU-V5). */ onDelegatedUsage?: (usage: RunUsage) => void; + /** + * Set when the call runs because a human approved it (`resumeAfterApproval()`, + * `agent.approvals.resolve()`): the decision's `note`. For the built-in + * `ask_question` tool it is the user's answer (LOU-X9). + */ + approval?: { note?: string }; } /**