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
- Structured output (LOU-V4): `createAgent({ output: zodSchema })` (and `ExecuteOptions.output`) makes the final reply a JSON object validated by the schema. `send()`, `run.result` and `agent.approvals.resolve()` resolve with it as `result.object`, typed `z.output<typeof schema>` from `send()` and `stream()` of `createAgent()` agents (`SimpleAgent`, `AgentRun` and `ExecutionResult` gain a type parameter that defaults to `unknown`); `result.text` keeps the raw JSON. The system prompt gets an `## Output format` section with the schema as JSON Schema, and each model call carries the new `GenerateOptions.responseFormat: { type: 'json', schema? }` hint, which the `ai`-SDK providers map to `experimental_output` JSON mode. The reply is parsed (a code fence is tolerated) and validated; when invalid, one repair step sends the model the issues (it counts against `maxSteps`), and a reply still invalid ends the run with the new `finishReason: 'output-invalid'`, no `object` and `outputError: { message, issues }`. `run.done` gains an optional `object` field (additive: the event schema version stays 1). See docs/structured-output.md.
- Background sub-agents (LOU-Y4): the `task` tool takes `background: true`, which starts the sub-agent and returns `{ taskId, status: 'running', agent }` at once. Agents with `subagents` also get `agent_status({ taskId? })` (`queued`/`running`/`done`/`failed`/`cancelled`/`awaiting-approval`, plus `elapsedMs`), `agent_await({ taskId | taskIds, timeoutMs? })` (the synchronous `task` result, or `status: 'timeout'`) and `agent_cancel({ taskId })`. `withSubagentOptions(subagents, { maxConcurrent })` bounds how many run at once per lead run (default 3; the rest queue). Background sub-agents inherit the lead run's runtime and `maxSubagentDepth`, and aborting the lead cancels them. Not yet (LOU-Y4.2): cancelling or awaiting them when the lead run finishes, and resuming one that paused for approval (it reports `awaiting-approval` with its `approvalId`). A user tool named `agent_status`, `agent_await` or `agent_cancel` now conflicts with `subagents`, like `task`. See docs/sub-agents.md.
- 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.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,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**: `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)
- **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)
- **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)
Expand Down Expand Up @@ -177,6 +178,7 @@ await AgentExecutor.execute({
| [Tools](docs/tools.md) | `defineTool()`, validation and errors, built-in tools, `ToolRegistry` |
| [Approvals](docs/approvals.md) | `needsApproval`, `agent.approvals`, the `approve` callback, `resumeAfterApproval()`, stores |
| [Sessions](docs/sessions.md) | Multi-turn conversations, `session.stream()`, session stores, `SqliteStore` |
| [Structured output](docs/structured-output.md) | `output: zodSchema`: typed `result.object`, the repair step, `'output-invalid'` |
| [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 |
Expand Down
16 changes: 15 additions & 1 deletion docs/api-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,18 @@ in). Pauses are kept in a per-agent `InMemoryApprovalStore` unless you pass
decides each call in code without pausing (`stream()` still ends at the
pause). See [Approvals](./approvals.md).

### Structured output

Pass `output: zodSchema` to `createAgent()` (or `AgentExecutor.execute()` /
`stream()`): the final reply must be a JSON object matching the schema, and
`send()` / `run.result` resolve with it validated as `result.object`, typed
`z.output<typeof schema>` (`result.text` keeps the raw JSON). Tools still run
first. Each model call carries a `responseFormat: { type: 'json', schema }`
hint, which the `ai`-SDK providers map to JSON mode. An invalid reply gets one
repair step listing the issues (it counts against `maxSteps`); if that is
invalid too, the run ends with `finishReason: 'output-invalid'` and
`outputError: { message, issues }`. See [Structured output](./structured-output.md).

### Skills

Pass `skills: [defineSkill({ name, description, content }), ...(await loadSkills(dir))]` to
Expand Down Expand Up @@ -153,7 +165,9 @@ How it behaves:
`result.finishReason` says why a run ended: the model's own reason for its last
turn (`'stop'`, `'length'`, `'tool_calls'`, `'content_filter'`, `'error'`),
`'awaiting-approval'` (paused on a tool call that needs a human), `'aborted'`
(cancelled with `signal`), or `'max-steps'`. `'max-steps'` means the `maxSteps`
(cancelled with `signal`), `'max-steps'`, or `'output-invalid'` (the reply
did not match the `output` schema even after the repair step, see
[Structured output](./structured-output.md)). `'max-steps'` means the `maxSteps`
budget (default 10) ran out while the model still wanted to continue, so the
reply may be empty or partial; a run that finishes naturally within the budget
keeps its `'stop'`. Steps carried over by `initialSteps` or an approval resume
Expand Down
2 changes: 1 addition & 1 deletion docs/streaming.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ The event types and their extra fields:
| `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. |
| `provider.retry` | `attempt: number`, `maxRetries: number`, `delayMs: number`, `error: { message: string, category?: string }`, `provider: string` | A model call failed and is retried after `delayMs` (`createAgent({ retry })` or any `withRetry()` provider). `attempt` is the attempt that failed (1 = first); `category` is `'rate-limit'`, `'timeout'`, ... and absent when unknown (a 5xx, for example). |
| `provider.fallback` | `from: string`, `to: string`, `error: { message: string }` | A model call still failed after its retries and the next provider takes over (`createAgent({ fallbackModels })` or any `withFallback()` provider). `from` and `to` are provider names. |
| `run.done` | `finishReason: string`, `text: string`, `usage?: { promptTokens, completionTokens, totalTokens }` | Last event of every run, exactly once, including aborted, failed and awaiting-approval runs. `finishReason` and `text` match `run.result` (`'max-steps'` when the `maxSteps` budget ran out while the model still wanted to continue); a failed run has `finishReason: 'error'`, `text: ''` and no `usage`. |
| `run.done` | `finishReason: string`, `text: string`, `usage?: { promptTokens, completionTokens, totalTokens }`, `object?: unknown` | Last event of every run, exactly once, including aborted, failed and awaiting-approval runs. `finishReason` and `text` match `run.result` (`'max-steps'` when the `maxSteps` budget ran out while the model still wanted to continue); a failed run has `finishReason: 'error'`, `text: ''` and no `usage`. `object` is `run.result`'s validated `object` for an agent with an `output` schema (see [Structured output](./structured-output.md)), absent otherwise. |

Optional fields are left out when they have no value. They are never
`undefined`, so `JSON.parse(JSON.stringify(event))` returns an equal object.
Expand Down
82 changes: 82 additions & 0 deletions docs/structured-output.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Structured output

Give an agent a zod schema as `output` and its final reply becomes a typed,
validated object: `result.object`. The agent can still call tools first; only
the final answer has to match the schema.

```ts
import { z } from 'zod';
import { createAgent } from '@loushy/build-ai-agent';

const agent = createAgent({
model: 'openai/gpt-4o-mini',
instructions: 'You report the weather.',
output: z.object({ city: z.string(), tempC: z.number(), summary: z.string() }),
});

const result = await agent.send('Weather in Paris?');
if (result.object) {
console.log(result.object.tempC); // typed: number
} else {
console.error(result.finishReason, result.outputError?.issues); // 'output-invalid', [{ path, message }]
}
```

`result.object` is typed as `z.output<typeof schema>`, so zod defaults and
transforms apply. `result.text` keeps the raw JSON text the model wrote.

## How it works

1. The system prompt gets an `## Output format` section asking for the final
answer as only a JSON object matching the schema, rendered as JSON Schema.
2. Every model call carries `responseFormat: { type: 'json', schema }` on
`GenerateOptions`. It is a hint: the built-in `ai`-SDK providers turn on
the model's JSON mode (with the schema, for models that support structured
outputs); a custom provider may use it or ignore it.
3. When the model replies without tool calls, the reply is parsed as JSON (a
```` ```json ```` code fence around it is tolerated) and validated with
the schema.
4. If it is invalid, the model gets one repair step: a user message
starting with `[output-invalid]` that lists the issues, for example
`1 issue (tempC: Expected number, received string)`. The repair step
counts against `maxSteps`, and there is none when the budget is spent.
5. Still invalid, the run resolves (it does not reject) with
`finishReason: 'output-invalid'`, no `object`, and `outputError`:
`{ message, issues: [{ path, message }] }`.

## Streaming

`agent.stream()` works the same way: `run.result` resolves with `object`,
and the final `run.done` event carries `object` (JSON-encoded) when the
reply was valid. The repair step shows up as one more
`step.start` / `step.done` pair. See [Streaming](./streaming.md).

## Without createAgent()

`AgentExecutor.execute()` and `AgentExecutor.stream()` take the same
`output` option. There `result.object` is `unknown`; parse it again with your
schema, or use `createAgent()` for the inferred type.

```ts
import { z } from 'zod';
import { AgentBuilder, AgentExecutor, createMockProvider } from '@loushy/build-ai-agent';

const Ticket = z.object({ title: z.string(), priority: z.enum(['low', 'high']) });
const agent = AgentBuilder.create().setName('triage').setPrompt('You triage bug reports.').build();
const result = await AgentExecutor.execute({ agent, input: 'The app crashes on login', provider: createMockProvider(), output: Ticket });
const ticket = result.object === undefined ? undefined : Ticket.parse(result.object);
```

## Testing

With `mockModel`, script the JSON text the model would write:

```ts
import { z } from 'zod';
import { createAgent } from '@loushy/build-ai-agent';
import { mockModel } from '@loushy/build-ai-agent/testing';

const agent = createAgent({ provider: mockModel(['{"city":"Paris","tempC":21}']), output: z.object({ city: z.string(), tempC: z.number() }) });
const { object } = await agent.send('Weather in Paris?');
console.log(object?.city); // 'Paris'
```
Loading
Loading