diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e7197fbe..b4e44a506 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 +- React hook (LOU-D15): new `@loushy/build-ai-agent/react` subpath with `useLoushyAgent(source, options?)`. `source` is `{ agent, sessionId? }` (in process, via `agent.stream()` or `agent.session({ id }).stream()`) or `{ url, headers?, fetch? }` (POSTs `{ input }` and reads the SSE or NDJSON event stream). It returns `messages` (text and tool calls with status, args and result), `status` (`idle`/`streaming`/`awaiting-approval`/`error`), `pendingApproval`, `error`, `usage`, `lastEvent`, `send()`, `stop()` (aborts; unmount aborts too), `approve(note?)` and `reject(note?)` (via `agent.approvals.resolve()` in process, or `POST ${approvalsUrl}/${id}` remotely). The framework-neutral `reduceAgentEvents()` reducer and `parseEventStream()` parser are exported from the same subpath. `react` (`^18 || ^19`) is an optional peer dependency. See docs/react.md. - Summarizing compaction (LOU-W3): `summarizeStrategy({ model, protectedTokens?, prompt?, maxSummaryTokens? })` replaces the turns before the protected tail with one `[Conversation summary]` user message written by `model` (an `LLMProvider` or a `"provider/model"` spec), keeping system and pinned messages and each assistant turn together with its tool results; if the summary call fails it falls back to pruning and reports `error`. `twoPhaseStrategy()` (the recommended strategy) prunes first and summarizes only when still above the threshold. `pinMessage()` / `isPinned()` and the new optional `Message.metadata` (never sent to providers) mark messages no built-in strategy compacts. `CompactionStrategy.compact()` may now be async, so `compactMessages()` returns a Promise; `CompactionResult` / `CompactionInfo` gain `summary` and `error`, and `CompactionInput` gains `thresholdTokens` and `signal`. A strategy that throws no longer fails the run: the hook reports the `error` to `onCompaction`. See docs/compaction.md. - `createAgent({ retry, fallbackModels })` (LOU-V7.2): `retry` (`withRetry()` options, or `false`) retries failed model calls and defaults to `{ maxRetries: 2 }` for models given as `provider/model` strings (or picked from the environment); a `provider` instance is wrapped only when `retry` is set. `fallbackModels` are `provider/model` strings tried in order once the primary model's retries are used up (`withFallback([withRetry(primary), withRetry(fallback), ...])`). `agent.stream()`, `session.stream()` and `AgentExecutor.stream()` report them as two new typed events, `provider.retry` (`attempt`, `maxRetries`, `delayMs`, `error: { message, category? }`, `provider`) and `provider.fallback` (`from`, `to`, `error: { message }`), for any `withRetry()` / `withFallback()` provider. Additive: the event schema version stays 1. - Context compaction (LOU-W2): `createCompactionHook()` returns an `AgentHook` that, before a model call estimated above `thresholdPercent` (default 0.9) of the model's context window, replaces tool results older than the newest `protectedTokens` (default 40,000) with a `[pruned: result, N chars]` marker, in the run's transcript itself. `compactMessages()` compacts a conversation by hand; `CompactionStrategy` / `pruneToolResultsStrategy()` make the strategy pluggable. See docs/compaction.md. diff --git a/README.md b/README.md index 4840f2384..e9a153562 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ agent can be an `agent.yaml` spec served with `npx loushy dev agent.yaml` - **Approvals**: `needsApproval` pauses a run; `agent.approvals.resolve()` continues it, or `approve` decides in code. [Approvals](docs/approvals.md) - **Sessions**: `agent.session()` keeps a multi-turn conversation in memory, files or SQLite. [Sessions](docs/sessions.md) - **Streaming**: `agent.stream()` and `session.stream()` yield typed, versioned JSON events ready for SSE. [Streaming](docs/streaming.md) +- **UI bindings**: `useLoushyAgent()` from `@loushy/build-ai-agent/react` turns the event stream into chat state, with approvals. [React](docs/react.md) - **Durable execution**: `sessionId` + `checkpointStore` resume a crashed or paused run without redoing finished tools. [Durable execution](docs/durable-execution.md) - **Cancellation, usage and cost**: pass an `AbortSignal`; every result carries token usage and USD cost for priced models. [API overview](docs/api-overview.md#cancellation) - **Providers**: OpenAI, Anthropic, OpenRouter, Ollama or a mock, with `withRetry()` and `withFallback()`. [Providers](docs/providers.md) @@ -177,6 +178,7 @@ await AgentExecutor.execute({ | [Approvals](docs/approvals.md) | `needsApproval`, `agent.approvals`, the `approve` callback, `resumeAfterApproval()`, stores | | [Sessions](docs/sessions.md) | Multi-turn conversations, `session.stream()`, session stores, `SqliteStore` | | [Streaming](docs/streaming.md) | `agent.stream()`: the typed event schema, terminal and SSE examples | +| [React](docs/react.md) | `useLoushyAgent()`: chat state from the event stream, in process or over HTTP; `reduceAgentEvents()`, `parseEventStream()` | | [Durable execution](docs/durable-execution.md) | Checkpoints, crash resume, approvals mid-batch, at-least-once tools | | [Sub-agents](docs/sub-agents.md) | The `subagents` option and its `task` tool, inheritance, approvals in sub-agents | | [Skills](docs/skills.md) | On-demand instructions: `defineSkill()`, `loadSkills()` | diff --git a/docs/api-overview.md b/docs/api-overview.md index 1cc49e3e4..8e2fc9e81 100644 --- a/docs/api-overview.md +++ b/docs/api-overview.md @@ -52,6 +52,15 @@ How the pieces fit: | `SessionAwaitingApprovalError` | Thrown by `execute()` when its `sessionId` is paused on an approval (see [Durable execution](./durable-execution.md)). | | `createDelegateTool()` | Wrap a child agent as a tool for multi-agent delegation. | +### UI bindings + +`@loushy/build-ai-agent/react` exports `useLoushyAgent(source, options?)`, a +React hook that runs an agent in process (`{ agent, sessionId? }`) or over HTTP +(`{ url }`) and returns `messages`, `status`, `pendingApproval`, +`send()`, `stop()`, `approve()` and `reject()`. Its framework-neutral parts, +`reduceAgentEvents()` and `parseEventStream()`, are exported too. See +[React](./react.md). + ### Sub-agents Pass `subagents: { researcher, writer }` (agents from `createAgent()` with a diff --git a/docs/react.md b/docs/react.md new file mode 100644 index 000000000..c5272d5e1 --- /dev/null +++ b/docs/react.md @@ -0,0 +1,189 @@ +# React + +`useLoushyAgent()` puts an agent behind a chat UI. It runs a turn, reads the +[typed event stream](./streaming.md) as it arrives and turns it into +render-ready state: messages with their text and tool calls, a `status` for +the composer, and the tool call waiting for approval, if any. + +It lives in the `@loushy/build-ai-agent/react` subpath. `react` (18 or 19) is +an optional peer dependency: install it in the app that uses the hook. + +```tsx +import { useLoushyAgent } from '@loushy/build-ai-agent/react'; + +export function Chat() { + const agent = useLoushyAgent({ url: '/api/agent' }); + + return ( +
{ + event.preventDefault(); + const input = new FormData(event.currentTarget).get('message'); + if (typeof input === 'string' && input.trim()) void agent.send(input); + event.currentTarget.reset(); + }} + > + {agent.messages.map((message) => ( +
+
{message.role}
+

{message.text}

+ {message.toolCalls.map((call) => ( + {call.name}: {call.status} + ))} +
+ ))} + {agent.pendingApproval && ( +

+ Run {agent.pendingApproval.toolName}? + + +

+ )} + + {agent.status === 'streaming' && } +
+ ); +} +``` + +## Sources + +The first argument says where the agent runs. + +| Source | What the hook does | +| ------ | ------------------ | +| `{ url, headers?, fetch? }` | **Remote.** `send(input)` POSTs `{ "input": "..." }` as JSON to `url` and reads the response body as an event stream: SSE (`data: {...}` lines) or newline-delimited JSON, one event per line. The server below writes exactly that. | +| `{ agent }` | **In process.** `send(input)` calls `agent.stream(input)`. Every turn is a new run with no history, like `agent.send()`. | +| `{ agent, sessionId }` | **In process, multi-turn.** The hook creates `agent.session({ id: sessionId })` once and streams each turn with `session.stream()`, so turns see the conversation so far. | + +In-process mode is for React code that runs where the agent can (Electron, +React Native, tests). In a browser, use the remote mode: model API keys belong +on the server. + +## Returned state + +| Field | What it is | +| ----- | ---------- | +| `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`. | +| `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). | + +Unmounting the component aborts the turn in flight. Events of a +[sub-agent](./sub-agents.md)'s run (they carry `subagent`) do not change +`messages`; read them from `lastEvent` if you want to show them. + +## Approvals + +When a tool with `needsApproval` is called, the run stops with +`approval.requested`. `status` becomes `'awaiting-approval'` and +`pendingApproval` holds the call. Then: + +- **In process**, `approve(note?)` and `reject(note?)` call + `agent.approvals.resolve({ id, approved, note })` (see + [Approvals](./approvals.md)). The continued run's text is appended to the + assistant message and `status` returns to `'idle'`, or to + `'awaiting-approval'` if the run pauses again. +- **Remote**, pass `{ approvalsUrl }` as the second argument. The hook POSTs + `{ "approved": true, "note": "..." }` to `${approvalsUrl}/${approvalId}` and + expects the `ApprovalOutcome` JSON the server below returns. +- **Remote without `approvalsUrl`**, `approve()` and `reject()` do nothing. + Show `pendingApproval` and resolve it through your own API, then `send()` + the next turn. + +## The server side + +A Node endpoint for the remote mode, with the approvals route: + +```ts +import { createServer, type IncomingMessage } from 'node:http'; +import { createAgent } from '@loushy/build-ai-agent'; +import type { ApprovalOutcome } from '@loushy/build-ai-agent/react'; + +const agent = createAgent({ model: 'openai/gpt-4o-mini' }); + +async function readJson(req: IncomingMessage): Promise> { + let body = ''; + for await (const chunk of req) body += chunk; + return body ? JSON.parse(body) : {}; +} + +createServer(async (req, res) => { + const body = await readJson(req); + const approval = /^\/api\/approvals\/(.+)$/.exec(req.url ?? ''); + + if (approval) { + const id = decodeURIComponent(approval[1]); + const note = typeof body.note === 'string' ? body.note : undefined; + const result = await agent.approvals.resolve({ id, approved: body.approved === true, note }); + const next = (await agent.approvals.list()).find((pending) => pending.id === result.approvalId); + const outcome: ApprovalOutcome = { text: result.text, finishReason: result.finishReason, usage: result.usage, approval: next }; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(outcome)); + return; + } + + const controller = new AbortController(); + res.on('close', () => controller.abort()); // the hook's stop() or unmount closes the request + res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' }); + for await (const event of agent.stream(String(body.input), { signal: controller.signal })) { + res.write(`data: ${JSON.stringify(event)}\n\n`); + } + res.end(); +}).listen(3000); +``` + +The client then uses +`useLoushyAgent({ url: '/api/agent' }, { approvalsUrl: '/api/approvals' })`. +For a multi-turn chat, keep an `agent.session({ id })` per conversation on the +server and call `session.stream()` instead of `agent.stream()`. + +## In process + +The hook takes the agent itself, too. Called from a custom hook: + +```ts +import { createAgent } from '@loushy/build-ai-agent'; +import { useLoushyAgent } from '@loushy/build-ai-agent/react'; + +const agent = createAgent({ model: 'openai/gpt-4o-mini' }); + +export function useSupportChat() { + const chat = useLoushyAgent({ agent, sessionId: 'support' }); + const lastReply = chat.messages.filter((m) => m.role === 'assistant').at(-1)?.text ?? ''; + return { ...chat, lastReply, busy: chat.status === 'streaming' }; +} +``` + +## Without React + +The hook is a thin wrapper. The state logic is `reduceAgentEvents(state, +event)`, a pure reducer over `AgentEvent`s and a few local actions +(`ui.send`, `ui.decide`, `ui.resumed`, `ui.stopped`, `ui.error`), and +`parseEventStream(response)` reads events back from a fetch response. Both are +exported from the same subpath, for a custom binding or a non-React client: + +```ts +import { initialAgentUIState, parseEventStream, reduceAgentEvents } from '@loushy/build-ai-agent/react'; + +let state = reduceAgentEvents(initialAgentUIState, { type: 'ui.send', input: 'Weather in Paris?' }); +const response = await fetch('http://localhost:3000/api/agent', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ input: 'Weather in Paris?' }), +}); +for await (const event of parseEventStream(response)) { + state = reduceAgentEvents(state, event); +} +console.log(state.status, state.messages.at(-1)?.text, state.usage?.totalTokens); +``` + +`parseEventStream()` skips blank lines, SSE comments and `event:`/`id:` +fields, and any line that is not a known event, so it reads both framings. +Breaking out of its loop cancels the response body. diff --git a/docs/streaming.md b/docs/streaming.md index 863eb1fe1..493230cb5 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -327,6 +327,9 @@ source.onmessage = (message) => { }; ``` +For a React chat UI over this kind of endpoint, see [React](./react.md): +`useLoushyAgent()` POSTs the input and reads the same `data:` lines. + ## Example: the full API `AgentExecutor.stream()` accepts every `execute()` option. Here a tool that diff --git a/llms-full.txt b/llms-full.txt index 431fe94cc..368704a29 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -72,6 +72,7 @@ agent can be an `agent.yaml` spec served with `npx loushy dev agent.yaml` - **Approvals**: `needsApproval` pauses a run; `agent.approvals.resolve()` continues it, or `approve` decides in code. [Approvals](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/approvals.md) - **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) - **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) +- **UI bindings**: `useLoushyAgent()` from `@loushy/build-ai-agent/react` turns the event stream into chat state, with approvals. [React](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/react.md) - **Durable execution**: `sessionId` + `checkpointStore` resume a crashed or paused run without redoing finished tools. [Durable execution](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/durable-execution.md) - **Cancellation, usage and cost**: pass an `AbortSignal`; every result carries token usage and USD cost for priced models. [API overview](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/api-overview.md#cancellation) - **Providers**: OpenAI, Anthropic, OpenRouter, Ollama or a mock, with `withRetry()` and `withFallback()`. [Providers](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/providers.md) @@ -176,6 +177,7 @@ await AgentExecutor.execute({ | [Approvals](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/approvals.md) | `needsApproval`, `agent.approvals`, the `approve` callback, `resumeAfterApproval()`, stores | | [Sessions](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/sessions.md) | Multi-turn conversations, `session.stream()`, session stores, `SqliteStore` | | [Streaming](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/streaming.md) | `agent.stream()`: the typed event schema, terminal and SSE examples | +| [React](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/react.md) | `useLoushyAgent()`: chat state from the event stream, in process or over HTTP; `reduceAgentEvents()`, `parseEventStream()` | | [Durable execution](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/durable-execution.md) | Checkpoints, crash resume, approvals mid-batch, at-least-once tools | | [Sub-agents](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/sub-agents.md) | The `subagents` option and its `task` tool, inheritance, approvals in sub-agents | | [Skills](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/skills.md) | On-demand instructions: `defineSkill()`, `loadSkills()` | @@ -709,6 +711,15 @@ How the pieces fit: | `SessionAwaitingApprovalError` | Thrown by `execute()` when its `sessionId` is paused on an approval (see [Durable execution](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/durable-execution.md)). | | `createDelegateTool()` | Wrap a child agent as a tool for multi-agent delegation. | +### UI bindings + +`@loushy/build-ai-agent/react` exports `useLoushyAgent(source, options?)`, a +React hook that runs an agent in process (`{ agent, sessionId? }`) or over HTTP +(`{ url }`) and returns `messages`, `status`, `pendingApproval`, +`send()`, `stop()`, `approve()` and `reject()`. Its framework-neutral parts, +`reduceAgentEvents()` and `parseEventStream()`, are exported too. See +[React](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/react.md). + ### Sub-agents Pass `subagents: { researcher, writer }` (agents from `createAgent()` with a @@ -3560,6 +3571,198 @@ All providers run on Node. The `cloudflare-worker` deploy target supports `mock`, `openai` and `anthropic`; `ollama` and `openrouter` need the `node-server` or `docker` target (see [Deployment](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/deployment.md#cloudflare-worker)). +# React + +Source: docs/react.md + +`useLoushyAgent()` puts an agent behind a chat UI. It runs a turn, reads the +[typed event stream](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/streaming.md) as it arrives and turns it into +render-ready state: messages with their text and tool calls, a `status` for +the composer, and the tool call waiting for approval, if any. + +It lives in the `@loushy/build-ai-agent/react` subpath. `react` (18 or 19) is +an optional peer dependency: install it in the app that uses the hook. + +```tsx +import { useLoushyAgent } from '@loushy/build-ai-agent/react'; + +export function Chat() { + const agent = useLoushyAgent({ url: '/api/agent' }); + + return ( +
{ + event.preventDefault(); + const input = new FormData(event.currentTarget).get('message'); + if (typeof input === 'string' && input.trim()) void agent.send(input); + event.currentTarget.reset(); + }} + > + {agent.messages.map((message) => ( +
+
{message.role}
+

{message.text}

+ {message.toolCalls.map((call) => ( + {call.name}: {call.status} + ))} +
+ ))} + {agent.pendingApproval && ( +

+ Run {agent.pendingApproval.toolName}? + + +

+ )} + + {agent.status === 'streaming' && } +
+ ); +} +``` + +## Sources + +The first argument says where the agent runs. + +| Source | What the hook does | +| ------ | ------------------ | +| `{ url, headers?, fetch? }` | **Remote.** `send(input)` POSTs `{ "input": "..." }` as JSON to `url` and reads the response body as an event stream: SSE (`data: {...}` lines) or newline-delimited JSON, one event per line. The server below writes exactly that. | +| `{ agent }` | **In process.** `send(input)` calls `agent.stream(input)`. Every turn is a new run with no history, like `agent.send()`. | +| `{ agent, sessionId }` | **In process, multi-turn.** The hook creates `agent.session({ id: sessionId })` once and streams each turn with `session.stream()`, so turns see the conversation so far. | + +In-process mode is for React code that runs where the agent can (Electron, +React Native, tests). In a browser, use the remote mode: model API keys belong +on the server. + +## Returned state + +| Field | What it is | +| ----- | ---------- | +| `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`. | +| `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). | + +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 +`messages`; read them from `lastEvent` if you want to show them. + +## Approvals + +When a tool with `needsApproval` is called, the run stops with +`approval.requested`. `status` becomes `'awaiting-approval'` and +`pendingApproval` holds the call. Then: + +- **In process**, `approve(note?)` and `reject(note?)` call + `agent.approvals.resolve({ id, approved, note })` (see + [Approvals](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/approvals.md)). The continued run's text is appended to the + assistant message and `status` returns to `'idle'`, or to + `'awaiting-approval'` if the run pauses again. +- **Remote**, pass `{ approvalsUrl }` as the second argument. The hook POSTs + `{ "approved": true, "note": "..." }` to `${approvalsUrl}/${approvalId}` and + expects the `ApprovalOutcome` JSON the server below returns. +- **Remote without `approvalsUrl`**, `approve()` and `reject()` do nothing. + Show `pendingApproval` and resolve it through your own API, then `send()` + the next turn. + +## The server side + +A Node endpoint for the remote mode, with the approvals route: + +```ts +import { createServer, type IncomingMessage } from 'node:http'; +import { createAgent } from '@loushy/build-ai-agent'; +import type { ApprovalOutcome } from '@loushy/build-ai-agent/react'; + +const agent = createAgent({ model: 'openai/gpt-4o-mini' }); + +async function readJson(req: IncomingMessage): Promise> { + let body = ''; + for await (const chunk of req) body += chunk; + return body ? JSON.parse(body) : {}; +} + +createServer(async (req, res) => { + const body = await readJson(req); + const approval = /^\/api\/approvals\/(.+)$/.exec(req.url ?? ''); + + if (approval) { + const id = decodeURIComponent(approval[1]); + const note = typeof body.note === 'string' ? body.note : undefined; + const result = await agent.approvals.resolve({ id, approved: body.approved === true, note }); + const next = (await agent.approvals.list()).find((pending) => pending.id === result.approvalId); + const outcome: ApprovalOutcome = { text: result.text, finishReason: result.finishReason, usage: result.usage, approval: next }; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(outcome)); + return; + } + + const controller = new AbortController(); + res.on('close', () => controller.abort()); // the hook's stop() or unmount closes the request + res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' }); + for await (const event of agent.stream(String(body.input), { signal: controller.signal })) { + res.write(`data: ${JSON.stringify(event)}\n\n`); + } + res.end(); +}).listen(3000); +``` + +The client then uses +`useLoushyAgent({ url: '/api/agent' }, { approvalsUrl: '/api/approvals' })`. +For a multi-turn chat, keep an `agent.session({ id })` per conversation on the +server and call `session.stream()` instead of `agent.stream()`. + +## In process + +The hook takes the agent itself, too. Called from a custom hook: + +```ts +import { createAgent } from '@loushy/build-ai-agent'; +import { useLoushyAgent } from '@loushy/build-ai-agent/react'; + +const agent = createAgent({ model: 'openai/gpt-4o-mini' }); + +export function useSupportChat() { + const chat = useLoushyAgent({ agent, sessionId: 'support' }); + const lastReply = chat.messages.filter((m) => m.role === 'assistant').at(-1)?.text ?? ''; + return { ...chat, lastReply, busy: chat.status === 'streaming' }; +} +``` + +## Without React + +The hook is a thin wrapper. The state logic is `reduceAgentEvents(state, +event)`, a pure reducer over `AgentEvent`s and a few local actions +(`ui.send`, `ui.decide`, `ui.resumed`, `ui.stopped`, `ui.error`), and +`parseEventStream(response)` reads events back from a fetch response. Both are +exported from the same subpath, for a custom binding or a non-React client: + +```ts +import { initialAgentUIState, parseEventStream, reduceAgentEvents } from '@loushy/build-ai-agent/react'; + +let state = reduceAgentEvents(initialAgentUIState, { type: 'ui.send', input: 'Weather in Paris?' }); +const response = await fetch('http://localhost:3000/api/agent', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ input: 'Weather in Paris?' }), +}); +for await (const event of parseEventStream(response)) { + state = reduceAgentEvents(state, event); +} +console.log(state.status, state.messages.at(-1)?.text, state.usage?.totalTokens); +``` + +`parseEventStream()` skips blank lines, SSE comments and `event:`/`id:` +fields, and any line that is not a known event, so it reads both framings. +Breaking out of its loop cancels the response body. + # Sessions Source: docs/sessions.md @@ -4178,6 +4381,9 @@ source.onmessage = (message) => { }; ``` +For a React chat UI over this kind of endpoint, see [React](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/react.md): +`useLoushyAgent()` POSTs the input and reads the same `data:` lines. + ## Example: the full API `AgentExecutor.stream()` accepts every `execute()` option. Here a tool that diff --git a/llms.txt b/llms.txt index 136ad16e0..8ddb61e09 100644 --- a/llms.txt +++ b/llms.txt @@ -19,6 +19,7 @@ - [Guardrails and sandboxing](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/guardrails.md): Two tools for running agent output and agent tools safely: guardrails are fail-closed checks over a proposed change before you act on it, and sandboxed tools run inside an isolated container instead of the host process. - [Tracing and observability](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/observability.md): `AgentExecutor.execute()` and `FlowExecutor.execute()` emit spans through a `TraceExporter`. - [Providers](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/providers.md): An `LLMProvider` is what an agent generates with. +- [React](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/react.md): `useLoushyAgent()` puts an agent behind a chat UI. - [Sessions](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/sessions.md): `agent.send(text)` is single-turn: every call starts from an empty history. - [Skills](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/skills.md): A skill is a named chunk of instructions (a markdown document) that the model loads only when it needs it. - [Streaming](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/streaming.md): `agent.stream()` runs an agent exactly like `agent.send()` and reports the run as a stream of typed events: model text as it arrives, every tool call, step boundaries, approval requests, and a final `run.done`. diff --git a/package-lock.json b/package-lock.json index f9f3e7271..4140524ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,10 +31,14 @@ "@types/dockerode": "^4.0.1", "@types/node": "^20", "@types/prompts": "^2.4.9", + "@types/react": "^18.3.31", + "@types/react-test-renderer": "^18.3.1", "@vitest/coverage-v8": "^1.0.0", "esbuild": "^0.25.12", "eslint": "^10.11.0", "fallow": "^3.31.0", + "react": "^18.3.1", + "react-test-renderer": "^18.3.1", "selfsigned": "^5.5.0", "tsup": "^8.0.0", "tsx": "^4.23.15", @@ -53,6 +57,7 @@ "@opentelemetry/api": "^1.9.1", "ai": "^4.3.19", "ollama-ai-provider": "^1.2.0", + "react": "^18 || ^19", "tsup": "^8.0.0", "zod": "^3.25.76" }, @@ -69,6 +74,9 @@ "ollama-ai-provider": { "optional": true }, + "react": { + "optional": true + }, "tsup": { "optional": true } @@ -3941,6 +3949,16 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/react-test-renderer": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-18.3.1.tgz", + "integrity": "sha512-vAhnk0tG2eGa37lkU9+s5SoroCsRI08xnsWFiAXOuPH2jqzMbcXvKExXViPi1P5fIklDeCvXqyrdmipFaSkZrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "^18" + } + }, "node_modules/@types/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", @@ -8973,6 +8991,35 @@ "node": ">=0.10.0" } }, + "node_modules/react-shallow-renderer": { + "version": "16.15.0", + "resolved": "https://registry.npmjs.org/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz", + "integrity": "sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4.1.1", + "react-is": "^16.12.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/react-test-renderer": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-18.3.1.tgz", + "integrity": "sha512-KkAgygexHUkQqtvvx/otwxtuFu5cVjfzTCtjXLH9boS19/Nbtg84zS7wIQn39G8IlrhThBpQsMKkq5ZHZIYFXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "react-is": "^18.3.1", + "react-shallow-renderer": "^16.15.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", diff --git a/package.json b/package.json index 82bbc9486..1498d6e08 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,11 @@ "types": "./dist/triggers/index.d.ts", "import": "./dist/triggers/index.mjs", "require": "./dist/triggers/index.js" + }, + "./react": { + "types": "./dist/react/index.d.ts", + "import": "./dist/react/index.mjs", + "require": "./dist/react/index.js" } }, "files": [ @@ -155,6 +160,7 @@ "@opentelemetry/api": "^1.9.1", "ai": "^4.3.19", "ollama-ai-provider": "^1.2.0", + "react": "^18 || ^19", "tsup": "^8.0.0", "zod": "^3.25.76" }, @@ -171,6 +177,9 @@ "ollama-ai-provider": { "optional": true }, + "react": { + "optional": true + }, "tsup": { "optional": true } @@ -191,10 +200,14 @@ "@types/dockerode": "^4.0.1", "@types/node": "^20", "@types/prompts": "^2.4.9", + "@types/react": "^18.3.31", + "@types/react-test-renderer": "^18.3.1", "@vitest/coverage-v8": "^1.0.0", "esbuild": "^0.25.12", "eslint": "^10.11.0", "fallow": "^3.31.0", + "react": "^18.3.1", + "react-test-renderer": "^18.3.1", "selfsigned": "^5.5.0", "tsup": "^8.0.0", "tsx": "^4.23.15", diff --git a/src/react/index.ts b/src/react/index.ts new file mode 100644 index 000000000..1b3ee5185 --- /dev/null +++ b/src/react/index.ts @@ -0,0 +1,26 @@ +/** + * `@loushy/build-ai-agent/react` (LOU-D15): the `useLoushyAgent()` hook, and + * the framework-neutral reducer and stream parser it is built on. + */ + +export { + useLoushyAgent, + type LocalAgentSource, + type LoushyAgentSource, + type RemoteAgentSource, + type UseLoushyAgentOptions, + type UseLoushyAgentResult, +} from './useLoushyAgent'; +export { + initialAgentUIState, + reduceAgentEvents, + type AgentUIAction, + type AgentUIState, + type AgentUIStatus, + type ApprovalOutcome, + type UIMessage, + type UIPendingApproval, + type UIToolCall, + type UIToolCallStatus, +} from './reducer'; +export { parseEventStream } from './parseEventStream'; diff --git a/src/react/parseEventStream.test.ts b/src/react/parseEventStream.test.ts new file mode 100644 index 000000000..b3591263d --- /dev/null +++ b/src/react/parseEventStream.test.ts @@ -0,0 +1,67 @@ +/** + * LOU-D15: parseEventStream() reads SSE and NDJSON framed event streams. + */ +import { describe, it, expect } from 'vitest'; +import { AGENT_EVENT_SCHEMA_VERSION, type AgentEvent } from '../execution/agentEvents'; +import { parseEventStream } from './parseEventStream'; + +const base = { runId: 'r1', timestamp: new Date(0).toISOString(), v: AGENT_EVENT_SCHEMA_VERSION }; +const sample: AgentEvent[] = [ + { ...base, seq: 0, type: 'run.start', agentName: 'a' }, + { ...base, seq: 1, type: 'text.delta', text: 'héllo\nworld' }, + { ...base, seq: 2, type: 'run.done', finishReason: 'stop', text: 'héllo\nworld' }, +]; + +/** A Response whose body arrives in `chunkSize`-byte pieces, so lines and UTF-8 characters are split across reads. */ +function chunked(text: string, chunkSize = 7): Response { + const bytes = new TextEncoder().encode(text); + const body = new ReadableStream({ + start(controller) { + for (let i = 0; i < bytes.length; i += chunkSize) controller.enqueue(bytes.slice(i, i + chunkSize)); + controller.close(); + }, + }); + return new Response(body); +} + +async function collect(response: { body: ReadableStream | null }): Promise { + const out: AgentEvent[] = []; + for await (const event of parseEventStream(response)) out.push(event); + return out; +} + +describe('parseEventStream (LOU-D15)', () => { + it('reads SSE framing, ignoring comments, event/id fields and blank lines', async () => { + const sse = `: keep-alive\n\n${sample.map((e) => `event: message\nid: ${e.seq}\ndata: ${JSON.stringify(e)}\n\n`).join('')}`; + expect(await collect(chunked(sse))).toEqual(sample); + expect(await collect(chunked(sse.replace(/\n/g, '\r\n'), 3))).toEqual(sample); + }); + + it('reads newline-delimited JSON, including a last line without a newline', async () => { + const ndjson = sample.map((e) => JSON.stringify(e)).join('\n'); + expect(await collect(chunked(ndjson))).toEqual(sample); + }); + + it('skips lines that are not agent events and handles an empty body', async () => { + const text = `not json\n{"broken":\n${JSON.stringify({ type: 'unknown.event', v: 1, seq: 0 })}\n${JSON.stringify(sample[0])}\n`; + expect(await collect(chunked(text))).toEqual([sample[0]]); + expect(await collect({ body: null })).toEqual([]); + }); + + it('breaking out of the loop cancels the body', async () => { + let cancelled = false; + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(`${JSON.stringify(sample[0])}\n`)); + }, + cancel() { + cancelled = true; + }, + }); + for await (const event of parseEventStream({ body })) { + expect(event.type).toBe('run.start'); + break; + } + expect(cancelled).toBe(true); + }); +}); diff --git a/src/react/parseEventStream.ts b/src/react/parseEventStream.ts new file mode 100644 index 000000000..c0817135a --- /dev/null +++ b/src/react/parseEventStream.ts @@ -0,0 +1,56 @@ +/** + * LOU-D15: reads {@link AgentEvent}s back from an HTTP response body, as + * written by the docs/streaming.md server: SSE (`data: {...}` lines) or + * newline-delimited JSON (one event per line). + */ + +import { isAgentEvent, type AgentEvent } from '../execution/agentEvents'; + +/** The event on one line, or `undefined` for blank lines, SSE comments/fields and unknown events. */ +function parseLine(line: string): AgentEvent | undefined { + const text = (line.startsWith('data:') ? line.slice(5) : line).trim(); + if (!text.startsWith('{')) return undefined; + try { + const value: unknown = JSON.parse(text); + return isAgentEvent(value) ? value : undefined; + } catch { + return undefined; + } +} + +/** + * Yields the events of a streamed response, in order. Accepts SSE framing + * (`data: `, blank-line separated; `event:`, `id:` and `:` comment + * lines are ignored) and plain newline-delimited JSON. Lines that are not + * a known {@link AgentEvent} are skipped. Breaking out of the loop cancels + * the body. + * + * @example + * ```ts + * const response = await fetch('/api/agent', { method: 'POST', body: JSON.stringify({ input: 'Hi' }) }); + * for await (const event of parseEventStream(response)) { + * if (event.type === 'text.delta') console.log(event.text); + * } + * ``` + */ +export async function* parseEventStream(response: { body: ReadableStream | null }): AsyncGenerator { + if (!response.body) return; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + try { + for (;;) { + const { value, done } = await reader.read(); + buffer += done ? decoder.decode() : decoder.decode(value, { stream: true }); + const lines = buffer.split(/\r?\n/); + buffer = done ? '' : (lines.pop() ?? ''); + for (const line of lines) { + const event = parseLine(line); + if (event) yield event; + } + if (done) return; + } + } finally { + reader.cancel().catch(() => undefined); + } +} diff --git a/src/react/react.test-d.ts b/src/react/react.test-d.ts new file mode 100644 index 000000000..8d761640c --- /dev/null +++ b/src/react/react.test-d.ts @@ -0,0 +1,58 @@ +/** + * LOU-D15: the public types of `@loushy/build-ai-agent/react`. + */ +import { describe, it, expectTypeOf } from 'vitest'; +import { createAgent } from '../createAgent'; +import { createMockProvider } from '../providers/mock'; +import type { AgentEvent, AgentEventError, AgentEventUsage } from '../execution/agentEvents'; +import { + initialAgentUIState, + parseEventStream, + reduceAgentEvents, + useLoushyAgent, + type AgentUIAction, + type AgentUIState, + type AgentUIStatus, + type LoushyAgentSource, + type UIMessage, + type UIPendingApproval, + type UIToolCallStatus, + type UseLoushyAgentResult, +} from './index'; + +describe('@loushy/build-ai-agent/react types', () => { + it('accepts an in-process agent or a URL as the source', () => { + const agent = createAgent({ provider: createMockProvider() }); + expectTypeOf({ agent }).toMatchTypeOf(); + expectTypeOf({ agent, sessionId: 'chat-1' }).toMatchTypeOf(); + expectTypeOf({ url: '/api/agent', headers: { Authorization: 'Bearer t' } }).toMatchTypeOf(); + expectTypeOf({ sessionId: 'x' }).not.toMatchTypeOf(); + expectTypeOf(useLoushyAgent).parameter(1).toEqualTypeOf<{ approvalsUrl?: string } | undefined>(); + }); + + it('returns typed state and commands', () => { + expectTypeOf(useLoushyAgent).returns.toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf<'idle' | 'streaming' | 'awaiting-approval' | 'error'>(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf<(input: string) => Promise>(); + expectTypeOf().toEqualTypeOf<(note?: string) => Promise>(); + expectTypeOf().toEqualTypeOf<() => void>(); + expectTypeOf().toEqualTypeOf<'user' | 'assistant'>(); + expectTypeOf().toEqualTypeOf(); + }); + + it('exposes the reducer and parser framework-free', () => { + expectTypeOf(initialAgentUIState).toEqualTypeOf(); + expectTypeOf(reduceAgentEvents).parameter(1).toEqualTypeOf(); + expectTypeOf(reduceAgentEvents).returns.toEqualTypeOf(); + expectTypeOf(parseEventStream).parameter(0).toMatchTypeOf<{ body: ReadableStream | null }>(); + expectTypeOf(parseEventStream).returns.toEqualTypeOf>(); + // @ts-expect-error - only known actions and events are accepted + reduceAgentEvents(initialAgentUIState, { type: 'ui.unknown' }); + }); +}); diff --git a/src/react/reducer.test.ts b/src/react/reducer.test.ts new file mode 100644 index 000000000..8e49e1aae --- /dev/null +++ b/src/react/reducer.test.ts @@ -0,0 +1,120 @@ +/** + * LOU-D15: reduceAgentEvents() over scripted event sequences, without React. + */ +import { describe, it, expect } from 'vitest'; +import { AGENT_EVENT_SCHEMA_VERSION, type AgentEvent, type AgentEventPayload } from '../execution/agentEvents'; +import { initialAgentUIState, reduceAgentEvents, type AgentUIAction, type AgentUIState } from './reducer'; + +const usage = { promptTokens: 3, completionTokens: 4, totalTokens: 7, inputTokens: 3, outputTokens: 4, estimated: false }; + +function events(...payloads: AgentEventPayload[]): AgentEvent[] { + return payloads.map( + (payload, seq) => ({ ...payload, runId: 'r1', seq, timestamp: new Date(0).toISOString(), v: AGENT_EVENT_SCHEMA_VERSION }) as AgentEvent + ); +} + +function reduce(...items: (AgentEvent | AgentUIAction)[]): AgentUIState { + return items.reduce(reduceAgentEvents, initialAgentUIState); +} + +const send: AgentUIAction = { type: 'ui.send', input: 'Weather in Paris?' }; + +describe('reduceAgentEvents (LOU-D15)', () => { + it('ui.send adds the user message and an empty assistant message, and starts streaming', () => { + const state = reduce(send); + expect(state.status).toBe('streaming'); + expect(state.messages).toEqual([ + { id: 'm0', role: 'user', text: 'Weather in Paris?', toolCalls: [] }, + { id: 'm1', role: 'assistant', text: '', toolCalls: [] }, + ]); + }); + + it('accumulates text deltas, then run.done sets idle and usage', () => { + const script = events( + { type: 'run.start', agentName: 'a' }, + { type: 'step.start', step: 1 }, + { type: 'text.delta', text: 'It is ' }, + { type: 'text.delta', text: 'sunny.' }, + { type: 'text.done', text: 'It is sunny.' }, + { type: 'step.done', step: 1, finishReason: 'stop' }, + { type: 'run.done', finishReason: 'stop', text: 'It is sunny.', usage } + ); + const midway = reduce(send, ...script.slice(0, 4)); + expect(midway.messages[1].text).toBe('It is sunny.'); + expect(midway.status).toBe('streaming'); + + const state = reduce(send, ...script); + expect(state.status).toBe('idle'); + expect(state.usage).toEqual(usage); + expect(state.lastEvent?.type).toBe('run.done'); + expect(state.messages.map((m) => m.text)).toEqual(['Weather in Paris?', 'It is sunny.']); + }); + + it('tracks the tool lifecycle by toolCallId', () => { + const [start1, start2, done2, error1] = events( + { type: 'tool.start', toolCallId: 'c1', toolName: 'a', args: { x: 1 } }, + { type: 'tool.start', toolCallId: 'c2', toolName: 'b', args: {} }, + { type: 'tool.done', toolCallId: 'c2', toolName: 'b', result: { ok: true }, durationMs: 1 }, + { type: 'tool.error', toolCallId: 'c1', toolName: 'a', error: { name: 'Error', message: 'boom' }, durationMs: 2 } + ); + expect(reduce(send, start1, start2).messages[1].toolCalls.map((c) => c.status)).toEqual(['running', 'running']); + expect(reduce(send, start1, start2, done2, error1).messages[1].toolCalls).toEqual([ + { id: 'c1', name: 'a', args: { x: 1 }, status: 'error', error: { name: 'Error', message: 'boom' } }, + { id: 'c2', name: 'b', args: {}, status: 'done', result: { ok: true } }, + ]); + }); + + it('approval.requested pauses; a decision and the resumed outcome finish the turn', () => { + const paused = reduce( + send, + ...events( + { type: 'text.delta', text: 'Sending.' }, + { type: 'approval.requested', approvalId: 'ap1', toolCallId: 'c1', toolName: 'send_email', args: { to: 'sam' } }, + { type: 'run.done', finishReason: 'awaiting-approval', text: '' } + ) + ); + expect(paused.status).toBe('awaiting-approval'); + expect(paused.pendingApproval).toEqual({ id: 'ap1', toolCallId: 'c1', toolName: 'send_email', args: { to: 'sam' } }); + expect(paused.messages[1].toolCalls[0]).toMatchObject({ id: 'c1', status: 'awaiting-approval' }); + + const deciding = reduceAgentEvents(paused, { type: 'ui.decide', approved: true }); + expect(deciding).toMatchObject({ status: 'streaming', pendingApproval: null }); + expect(deciding.messages[1].toolCalls[0].status).toBe('running'); + + const done = reduceAgentEvents(deciding, { type: 'ui.resumed', outcome: { text: 'Sent.', finishReason: 'stop', usage } }); + expect(done).toMatchObject({ status: 'idle', usage }); + expect(done.messages[1]).toMatchObject({ text: 'Sending.\n\nSent.', toolCalls: [{ status: 'done' }] }); + + const rejected = reduceAgentEvents(paused, { type: 'ui.decide', approved: false }); + expect(rejected.messages[1].toolCalls[0].status).toBe('rejected'); + }); + + 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 } }); + expect(state).toMatchObject({ status: 'awaiting-approval', pendingApproval: next }); + expect(state.messages[1].toolCalls).toEqual([{ id: 'c2', name: 'pay', args: {}, status: 'awaiting-approval' }]); + }); + + it('error events are kept; run.done with error sets the error status', () => { + const error = { name: 'ProviderError', message: 'down' }; + const state = reduce(send, ...events({ type: 'error', error }, { type: 'run.done', finishReason: 'error', text: '' })); + expect(state).toMatchObject({ status: 'error', error }); + expect(reduce(send, { type: 'ui.error', error })).toMatchObject({ status: 'error', error }); + }); + + it('ui.stopped ends a streaming run only; sub-agent events only update lastEvent', () => { + expect(reduce(send, { type: 'ui.stopped' }).status).toBe('idle'); + expect(reduce({ type: 'ui.stopped' })).toBe(initialAgentUIState); + + const [inner] = events({ type: 'text.delta', text: 'inner' }); + const state = reduce(send, { ...inner, subagent: { name: 'researcher', depth: 1, toolCallId: 'c1' } }); + expect(state.messages[1].text).toBe(''); + expect(state.lastEvent?.subagent?.name).toBe('researcher'); + }); + + it('events without ui.send still build an assistant message', () => { + const state = reduce(...events({ type: 'text.delta', text: 'Hi' })); + expect(state.messages).toEqual([{ id: 'm0', role: 'assistant', text: 'Hi', toolCalls: [] }]); + }); +}); diff --git a/src/react/reducer.ts b/src/react/reducer.ts new file mode 100644 index 000000000..3ffe8283a --- /dev/null +++ b/src/react/reducer.ts @@ -0,0 +1,177 @@ +/** + * LOU-D15: the framework-neutral state behind `useLoushyAgent()`: a pure + * reducer from the typed {@link AgentEvent} stream (docs/streaming.md), plus + * a few local actions, to chat UI state. A UI binding (React here, Vue or + * Svelte later) only holds this state and dispatches into it. + */ + +import type { AgentEvent, AgentEventError, AgentEventUsage } from '../execution/agentEvents'; + +/** Where a tool call stands: running, paused for approval, or finished. */ +export type UIToolCallStatus = 'running' | 'awaiting-approval' | 'done' | 'error' | 'rejected'; + +/** A tool call of an assistant message. */ +export interface UIToolCall { + /** The `toolCallId` of its events. */ + id: string; + name: string; + args: Record; + status: UIToolCallStatus; + /** The tool's result, from `tool.done`. */ + result?: unknown; + /** Why it failed, from `tool.error`. */ + error?: AgentEventError; +} + +/** One chat bubble: the user's input, or the agent's text and tool calls for that turn. */ +export interface UIMessage { + id: string; + role: 'user' | 'assistant'; + text: string; + /** Tool calls in start order (always empty for user messages). */ + toolCalls: UIToolCall[]; +} + +/** `streaming` while a run (or an approval's continuation) is in flight. */ +export type AgentUIStatus = 'idle' | 'streaming' | 'awaiting-approval' | 'error'; + +/** The tool call a run paused on, from `approval.requested`. */ +export interface UIPendingApproval { + id: string; + toolCallId: string; + toolName: string; + args: Record; +} + +/** How a run continued after an approval decision (built from `agent.approvals.resolve()`'s result). */ +export interface ApprovalOutcome { + text: string; + finishReason: string; + usage?: AgentEventUsage; + /** Set when the continued run paused on another approval. */ + approval?: UIPendingApproval | null; +} + +export interface AgentUIState { + messages: UIMessage[]; + status: AgentUIStatus; + pendingApproval: UIPendingApproval | null; + /** The last `error` event, or a transport failure. */ + error: AgentEventError | null; + /** Token usage of the last finished run (`run.done`). */ + usage: AgentEventUsage | null; + lastEvent: AgentEvent | null; +} + +/** Local actions, besides the events themselves. */ +export type AgentUIAction = + | { type: 'ui.send'; input: string } + | { type: 'ui.decide'; approved: boolean } + | { type: 'ui.resumed'; outcome: ApprovalOutcome } + | { type: 'ui.stopped' } + | { type: 'ui.error'; error: AgentEventError }; + +export const initialAgentUIState: AgentUIState = { + messages: [], + status: 'idle', + pendingApproval: null, + error: null, + usage: null, + lastEvent: null, +}; + +function settledStatus(finishReason: string): AgentUIStatus { + if (finishReason === 'awaiting-approval') return 'awaiting-approval'; + return finishReason === 'error' ? 'error' : 'idle'; +} + +/** Applies `update` to the last assistant message, appending an empty one first if the last message is not one. */ +function onAssistant(messages: UIMessage[], update: (message: UIMessage) => UIMessage): UIMessage[] { + const last = messages[messages.length - 1]; + if (last?.role === 'assistant') return [...messages.slice(0, -1), update(last)]; + return [...messages, update({ id: `m${messages.length}`, role: 'assistant', text: '', toolCalls: [] })]; +} + +/** Patches the tool call `id` of the last assistant message; adds `added` (patched) when it is not there. */ +function patchTool(messages: UIMessage[], id: string, patch: Partial, added?: UIToolCall): UIMessage[] { + return onAssistant(messages, (message) => { + const known = message.toolCalls.some((call) => call.id === id); + const toolCalls = known + ? message.toolCalls.map((call) => (call.id === id ? { ...call, ...patch } : call)) + : added + ? [...message.toolCalls, { ...added, ...patch }] + : message.toolCalls; + return { ...message, toolCalls }; + }); +} + +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' }); + return { ...state, messages, status: 'awaiting-approval', pendingApproval: approval }; +} + +function resumed(state: AgentUIState, { text, finishReason, usage, approval }: ApprovalOutcome): AgentUIState { + const messages = onAssistant(state.messages, (message) => ({ + ...message, + text: message.text && text ? `${message.text}\n\n${text}` : message.text || text, + toolCalls: message.toolCalls.map((call) => (call.status === 'running' ? { ...call, status: 'done' } : call)), + })); + const next = { ...state, messages, status: settledStatus(finishReason), usage: usage ?? state.usage }; + return approval ? pause(next, approval) : next; +} + +/** + * The next UI state after an {@link AgentEvent} or a local {@link AgentUIAction}. + * Pure, so it works with React's `useReducer`, a Vue `ref` or a Svelte store. + * Events of a sub-agent's run (with `subagent`) only update `lastEvent`, + * except `approval.requested`. + * + * @example + * ```ts + * let state = reduceAgentEvents(initialAgentUIState, { type: 'ui.send', input: 'Hi' }); + * for await (const event of agent.stream('Hi')) state = reduceAgentEvents(state, event); + * ``` + */ +export function reduceAgentEvents(state: AgentUIState, event: AgentEvent | AgentUIAction): AgentUIState { + switch (event.type) { + case 'ui.send': { + const user: UIMessage = { id: `m${state.messages.length}`, role: 'user', text: event.input, toolCalls: [] }; + const messages = onAssistant([...state.messages, user], (message) => message); + return { ...state, messages, status: 'streaming', error: null, pendingApproval: null }; + } + case 'ui.decide': { + const id = state.pendingApproval?.toolCallId ?? ''; + const messages = patchTool(state.messages, id, { status: event.approved ? 'running' : 'rejected' }); + return { ...state, messages, status: 'streaming', pendingApproval: null }; + } + case 'ui.resumed': + return resumed(state, event.outcome); + case 'ui.stopped': + return state.status === 'streaming' ? { ...state, status: 'idle' } : state; + case 'ui.error': + return { ...state, status: 'error', error: event.error, pendingApproval: null }; + } + const next = { ...state, lastEvent: event }; + if (event.subagent && event.type !== 'approval.requested') return next; + switch (event.type) { + case 'text.delta': + return { ...next, messages: onAssistant(state.messages, (m) => ({ ...m, text: m.text + event.text })) }; + case 'tool.start': { + const call: UIToolCall = { id: event.toolCallId, name: event.toolName, args: event.args, status: 'running' }; + return { ...next, messages: patchTool(state.messages, call.id, {}, call) }; + } + case 'tool.done': + return { ...next, messages: patchTool(state.messages, event.toolCallId, { status: 'done', result: event.result }) }; + 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 }); + case 'error': + return { ...next, error: event.error }; + case 'run.done': + return { ...next, status: settledStatus(event.finishReason), usage: event.usage ?? state.usage }; + default: + return next; + } +} diff --git a/src/react/useLoushyAgent.test.ts b/src/react/useLoushyAgent.test.ts new file mode 100644 index 000000000..4898a6825 --- /dev/null +++ b/src/react/useLoushyAgent.test.ts @@ -0,0 +1,217 @@ +// @vitest-environment node +/** + * LOU-D15: useLoushyAgent() rendered with react-test-renderer (no DOM + * needed), in process with mockModel and remote with a scripted fetch. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { createElement } from 'react'; +import { act, create, type ReactTestRenderer } from 'react-test-renderer'; +import { z } from 'zod'; +import { createAgent } from '../createAgent'; +import { defineTool } from '../tools/defineTool'; +import { mockModel } from '../testing'; +import { AGENT_EVENT_SCHEMA_VERSION, type AgentEvent } from '../execution/agentEvents'; +import { useLoushyAgent, type LoushyAgentSource, type UseLoushyAgentOptions, type UseLoushyAgentResult } from './useLoushyAgent'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +let hook: UseLoushyAgentResult; +let renderer: ReactTestRenderer | undefined; +const statuses: string[] = []; + +function Probe({ source, options }: { source: LoushyAgentSource; options?: UseLoushyAgentOptions }) { + hook = useLoushyAgent(source, options); + statuses.push(hook.status); + return null; +} + +function mount(source: LoushyAgentSource, options?: UseLoushyAgentOptions): void { + statuses.length = 0; + act(() => { + renderer = create(createElement(Probe, { source, options })); + }); +} + +afterEach(() => { + act(() => renderer?.unmount()); + renderer = undefined; +}); + +function emailAgent(...turns: Parameters[0]) { + const execute = vi.fn(async ({ to }: { to: string }) => `sent to ${to}`); + const tool = defineTool({ name: 'send_email', description: 'Sends an email', input: z.object({ to: z.string() }), needsApproval: true, execute }); + return { agent: createAgent({ provider: mockModel(turns), tools: [tool] }), execute }; +} + +const callEmail = { toolCalls: [{ name: 'send_email', args: { to: 'sam' }, id: 'call_email' }] }; + +describe('useLoushyAgent in process (LOU-D15)', () => { + it('send() streams the reply into messages and ends idle with usage', async () => { + const agent = createAgent({ provider: mockModel([{ text: 'Hello there!', usage: { inputTokens: 5, outputTokens: 3 } }]) }); + mount({ agent }); + expect(hook.status).toBe('idle'); + + await act(() => hook.send('Hi')); + + expect(statuses).toContain('streaming'); + expect(hook.status).toBe('idle'); + expect(hook.messages.map(({ role, text }) => ({ role, text }))).toEqual([ + { role: 'user', text: 'Hi' }, + { role: 'assistant', text: 'Hello there!' }, + ]); + expect(hook.usage?.totalTokens).toBe(8); + expect(hook.lastEvent?.type).toBe('run.done'); + expect(hook.error).toBeNull(); + }); + + it('with sessionId, turns share one session', async () => { + const model = mockModel(['Nice to meet you, Ali.', 'You are Ali.']); + mount({ agent: createAgent({ provider: model }), sessionId: 'chat-1' }); + + await act(() => hook.send('My name is Ali.')); + await act(() => hook.send('What is my name?')); + + expect(model.lastCall?.messages.some((m) => m.content === 'My name is Ali.')).toBe(true); + expect(hook.messages.map((m) => m.text)).toEqual(['My name is Ali.', 'Nice to meet you, Ali.', 'What is my name?', 'You are Ali.']); + }); + + it('pauses for approval and approve() continues the run through agent.approvals', async () => { + const { agent, execute } = emailAgent(callEmail, 'Email sent.'); + mount({ agent }); + + await act(() => hook.send('Email Sam')); + expect(hook.status).toBe('awaiting-approval'); + expect(hook.pendingApproval).toMatchObject({ toolName: 'send_email', args: { to: 'sam' } }); + + await act(() => hook.approve()); + + expect(execute).toHaveBeenCalledTimes(1); + expect(hook.status).toBe('idle'); + expect(hook.pendingApproval).toBeNull(); + expect(hook.messages[1]).toMatchObject({ text: 'Email sent.', toolCalls: [{ name: 'send_email', status: 'done' }] }); + }); + + it('reject() gives the model a rejection and marks the call rejected', async () => { + const { agent, execute } = emailAgent(callEmail, 'OK, not sent.'); + mount({ agent }); + + await act(() => hook.send('Email Sam')); + await act(() => hook.reject('not today')); + + expect(execute).not.toHaveBeenCalled(); + expect(hook.status).toBe('idle'); + expect(hook.messages[1]).toMatchObject({ text: 'OK, not sent.', toolCalls: [{ status: 'rejected' }] }); + }); + + it('a failing run ends in the error status', async () => { + mount({ agent: createAgent({ provider: mockModel([{ error: new Error('provider down') }]) }) }); + await act(() => hook.send('Hi')); + expect(hook.status).toBe('error'); + expect(hook.error?.message).toMatch(/provider down/); + }); +}); + +const base = { runId: 'r1', timestamp: new Date(0).toISOString(), v: AGENT_EVENT_SCHEMA_VERSION }; +const sse = (events: AgentEvent[]) => events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join(''); + +/** A fetch whose response body streams `body` and, unless `close` is false, ends; aborting the request errors it. */ +function scriptedFetch(responses: { body: string; close?: boolean; status?: number }[]) { + const signals: AbortSignal[] = []; + const fetchMock = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + const { body, close = true, status = 200 } = responses.shift() ?? { body: '' }; + const signal = init?.signal ?? new AbortController().signal; + signals.push(signal); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(body)); + if (close) controller.close(); + else signal.addEventListener('abort', () => controller.error(signal.reason)); + }, + }); + return new Response(stream, { status }); + }); + return { fetch: fetchMock as unknown as typeof fetch, fetchMock, signals }; +} + +describe('useLoushyAgent remote (LOU-D15)', () => { + it('POSTs { input } with headers and reads the SSE event stream', async () => { + const remote = scriptedFetch([ + { + body: sse([ + { ...base, seq: 0, type: 'run.start', agentName: 'a' }, + { ...base, seq: 1, type: 'text.delta', text: 'Hi from the server' }, + { ...base, seq: 2, type: 'run.done', finishReason: 'stop', text: 'Hi from the server' }, + ]), + }, + ]); + mount({ url: '/api/agent', headers: { Authorization: 'Bearer t' }, fetch: remote.fetch }); + + await act(() => hook.send('Hello')); + + const [url, init] = remote.fetchMock.mock.calls[0]; + expect(url).toBe('/api/agent'); + expect(init).toMatchObject({ method: 'POST', body: JSON.stringify({ input: 'Hello' }), headers: { Authorization: 'Bearer t' } }); + expect(hook.status).toBe('idle'); + expect(hook.messages[1].text).toBe('Hi from the server'); + }); + + it('approve() POSTs to approvalsUrl and applies the outcome; without it, it does nothing', async () => { + const paused = sse([ + { ...base, seq: 0, type: 'approval.requested', approvalId: 'ap 1', toolCallId: 'c1', toolName: 'pay', args: {} }, + { ...base, seq: 1, type: 'run.done', finishReason: 'awaiting-approval', text: '' }, + ]); + const remote = scriptedFetch([{ body: paused }, { body: JSON.stringify({ text: 'Paid.', finishReason: 'stop' }) }, { body: paused }]); + mount({ url: '/api/agent', fetch: remote.fetch }, { approvalsUrl: '/api/approvals' }); + + await act(() => hook.send('Pay')); + expect(hook.status).toBe('awaiting-approval'); + await act(() => hook.approve('ok')); + + expect(remote.fetchMock.mock.calls[1][0]).toBe('/api/approvals/ap%201'); + expect(remote.fetchMock.mock.calls[1][1]?.body).toBe(JSON.stringify({ approved: true, note: 'ok' })); + expect(hook.status).toBe('idle'); + expect(hook.messages[1].text).toBe('Paid.'); + + act(() => renderer?.unmount()); + mount({ url: '/api/agent', fetch: remote.fetch }); + await act(() => hook.send('Pay')); + await act(() => hook.approve()); + expect(remote.fetchMock).toHaveBeenCalledTimes(3); + expect(hook.status).toBe('awaiting-approval'); + }); + + it('a failed response sets the error status', async () => { + mount({ url: '/api/agent', fetch: scriptedFetch([{ body: 'nope', status: 500 }]).fetch }); + await act(() => hook.send('Hi')); + expect(hook.status).toBe('error'); + expect(hook.error?.message).toMatch(/500/); + }); + + it('stop() aborts the request and returns to idle; unmount aborts too', async () => { + const open = sse([{ ...base, seq: 0, type: 'text.delta', text: 'partial' }]); + const remote = scriptedFetch([{ body: open, close: false }, { body: open, close: false }]); + mount({ url: '/api/agent', fetch: remote.fetch }); + + let sending: Promise = Promise.resolve(); + act(() => { + sending = hook.send('Long story'); + }); + await act(() => vi.waitFor(() => expect(hook.messages[1]?.text).toBe('partial'))); + expect(hook.status).toBe('streaming'); + await act(async () => { + hook.stop(); + await sending; + }); + expect(remote.signals[0].aborted).toBe(true); + expect(hook.status).toBe('idle'); + expect(hook.error).toBeNull(); + + act(() => { + void hook.send('Again'); + }); + await act(() => vi.waitFor(() => expect(remote.signals).toHaveLength(2))); + act(() => renderer?.unmount()); + renderer = undefined; + expect(remote.signals[1].aborted).toBe(true); + }); +}); diff --git a/src/react/useLoushyAgent.ts b/src/react/useLoushyAgent.ts new file mode 100644 index 000000000..1017e4517 --- /dev/null +++ b/src/react/useLoushyAgent.ts @@ -0,0 +1,155 @@ +/** + * LOU-D15: `useLoushyAgent()`, a React hook over an agent's typed event + * stream. The state logic lives in the framework-neutral reducer.ts; this + * file only runs the stream (in process or over HTTP) and dispatches. + */ + +import { useCallback, useEffect, useReducer, useRef } from 'react'; +import type { SimpleAgent } from '../createAgent'; +import type { AgentEvent, AgentEventError } from '../execution/agentEvents'; +import type { AgentSession } from '../session/AgentSession'; +import { parseEventStream } from './parseEventStream'; +import { + initialAgentUIState, + reduceAgentEvents, + type AgentUIAction, + type AgentUIState, + type ApprovalOutcome, +} from './reducer'; + +/** Runs a `createAgent()` agent in this process; with `sessionId`, in one `agent.session()` that keeps the conversation. */ +export interface LocalAgentSource { + agent: SimpleAgent; + sessionId?: string; +} + +/** An HTTP endpoint that takes `POST { input }` and answers with the run's events (SSE or NDJSON). */ +export interface RemoteAgentSource { + url: string; + headers?: Record; + fetch?: typeof fetch; +} + +export type LoushyAgentSource = LocalAgentSource | RemoteAgentSource; + +export interface UseLoushyAgentOptions { + /** + * Remote mode: `approve()`/`reject()` POST `{ approved, note }` to + * `${approvalsUrl}/${approvalId}` and read an {@link ApprovalOutcome} JSON + * response. Without it they do nothing: resolve `pendingApproval` yourself. + */ + approvalsUrl?: string; +} + +export interface UseLoushyAgentResult extends AgentUIState { + /** Starts a turn (aborting one still running). */ + send(input: string): Promise; + /** Aborts the run in flight; it ends with `finishReason: 'aborted'`. */ + stop(): void; + approve(note?: string): Promise; + reject(note?: string): Promise; +} + +type Emit = (action: AgentEvent | AgentUIAction) => void; + +function toEventError(error: unknown): AgentEventError { + return error instanceof Error ? { name: error.name, message: error.message } : { name: 'Error', message: String(error) }; +} + +type CachedSession = { agent: SimpleAgent; id: string; session: AgentSession }; + +/** The agent, or its session `sessionId` (created once per agent and id, so it keeps the conversation). */ +function streamTarget({ agent, sessionId: id }: LocalAgentSource, cache: { current: CachedSession | null }) { + if (id === undefined) return agent; + if (cache.current?.agent !== agent || cache.current.id !== id) cache.current = { agent, id, session: agent.session({ id }) }; + return cache.current.session; +} + +async function post(source: RemoteAgentSource, url: string, body: unknown, signal: AbortSignal): Promise { + const headers = { 'Content-Type': 'application/json', ...source.headers }; + const response = await (source.fetch ?? fetch)(url, { method: 'POST', headers, body: JSON.stringify(body), signal }); + if (!response.ok) throw new Error(`POST ${url} failed with ${response.status}`); + return response; +} + +/** + * Chat UI state for an agent: `messages` stream in as the run's events + * arrive, `status` drives the composer, and a paused tool call shows up as + * `pendingApproval` until `approve()` or `reject()`. Unmounting aborts the + * run in flight. See docs/react.md. + * + * @example + * ```ts + * const { messages, status, send } = useLoushyAgent({ url: '/api/agent' }); + * ``` + */ +export function useLoushyAgent(source: LoushyAgentSource, options: UseLoushyAgentOptions = {}): UseLoushyAgentResult { + const [state, dispatch] = useReducer(reduceAgentEvents, initialAgentUIState); + const latest = useRef({ source, options, state }); + latest.current = { source, options, state }; + const controller = useRef(null); + const session = useRef(null); + + useEffect(() => () => controller.current?.abort(), []); + + /** Runs `task` as the current run; once another run replaces it, its events are dropped. */ + const run = useCallback(async (task: (emit: Emit, signal: AbortSignal) => Promise) => { + controller.current?.abort(); + const mine = (controller.current = new AbortController()); + const emit: Emit = (action) => { + if (controller.current === mine) dispatch(action); + }; + try { + await task(emit, mine.signal); + } catch (error) { + if (!mine.signal.aborted) emit({ type: 'ui.error', error: toEventError(error) }); + } finally { + if (mine.signal.aborted) emit({ type: 'ui.stopped' }); + } + }, []); + + const send = useCallback( + (input: string) => + run(async (emit, signal) => { + emit({ type: 'ui.send', input }); + const { source } = latest.current; + let events: AsyncIterable; + if ('agent' in source) { + events = streamTarget(source, session).stream(input, { signal }); + } else { + events = parseEventStream(await post(source, source.url, { input }, signal)); + } + for await (const event of events) emit(event); + }), + [run] + ); + + const decide = useCallback( + (approved: boolean, note?: string) => { + const { source, options, state } = latest.current; + const pending = state.pendingApproval; + if (!pending || !('agent' in source || options.approvalsUrl)) return Promise.resolve(); + return run(async (emit, signal) => { + emit({ type: 'ui.decide', approved }); + const decision = { id: pending.id, approved, note }; + let outcome: ApprovalOutcome; + if ('agent' in source) { + const result = await source.agent.approvals.resolve(decision, { signal }); + const approval = (await source.agent.approvals.list()).find((a) => a.id === result.approvalId); + outcome = { text: result.text, finishReason: result.finishReason, usage: result.usage, approval }; + } else { + const url = `${options.approvalsUrl}/${encodeURIComponent(pending.id)}`; + outcome = (await (await post(source, url, { approved, note }, signal)).json()) as ApprovalOutcome; + } + emit({ type: 'ui.resumed', outcome }); + }); + }, + [run] + ); + + const stop = useCallback(() => controller.current?.abort(), []); + const approve = useCallback((note?: string) => decide(true, note), [decide]); + const reject = useCallback((note?: string) => decide(false, note), [decide]); + + return { ...state, send, stop, approve, reject }; +} diff --git a/tsup.config.ts b/tsup.config.ts index 25a55e8c1..83c5e7220 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -38,6 +38,7 @@ export default defineConfig({ 'execution/hooks': 'src/execution/hooks.ts', 'storage/sqlite/index': 'src/storage/sqlite/index.ts', 'triggers/index': 'src/triggers/index.ts', + 'react/index': 'src/react/index.ts', }, format: ['cjs', 'esm'], dts: true,