Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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".
- Agent Forge time travel API (LOU-D45): Agent Forge's `FileCheckpointStore` keeps the bounded per-session checkpoint history (`history()`, `historyLimit` option, `delete({ keepHistory })`) with the same semantics as `LocalStorageCheckpointStore`, checked by the SDK's history contract suite. The runtime control server gains `GET /runs/:id/history` (each step's status, save time, finish reason, tool calls, tokens and cost), `POST /runs/:id/fork` (`{ fromStep, patch?: { toolResult?, appendInput?, businessState? } }`, forks with `AgentExecutor.fork()` and starts the fork as a run of its own) and `GET /runs/compare?a=&b=` (`compareTrajectories()`). Fixed on the way: Agent Forge's chat transcript compiles against multimodal `Message.content` (LOU-V11) again, keeping each message's text. See docs/agent-forge.md#time-travel.
- 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 `<sessionId>.fork-<n>`), 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 <name> (<mimeType>) 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": "<base64>" }` 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.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 4 additions & 2 deletions docs/api-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
47 changes: 47 additions & 0 deletions docs/approvals.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion docs/react.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion docs/streaming.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ The event types and their extra fields:
| `tool.start` | `toolCallId: string`, `toolName: string`, `args: Record<string, unknown>` | 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<string, unknown>` | 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<string, unknown>`, `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<string, unknown>`, `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. |
Expand Down
1 change: 1 addition & 0 deletions docs/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<server>__<tool>`; see [Connect MCP servers](./configuration.md#connect-mcp-servers-mcpservers-connectmcp). |
Expand Down
Loading
Loading