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
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,12 @@ dashboard — any provider, any deploy target, no lock-in.
- **Human-in-the-loop** — flag a tool `needsApproval` and pause execution until a human approves or rejects it, then `resumeAfterApproval()` from any process
- **Durable execution** — pass a `sessionId` + `checkpointStore` and a crash mid-conversation resumes instead of restarting
- **Cancellation** — pass an `AbortSignal` (`agent.send(input, { signal })`) to stop a run; it resolves with `finishReason: 'aborted'` and the transcript so far, and the signal reaches the provider, tools and delegated agents
<<<<<<< HEAD
- **Streaming events** — `for await (const event of agent.stream(input))` yields a typed, versioned, JSON-serializable event stream (`text.delta` tokens as they arrive, `tool.start`/`tool.done`, steps, approvals, a final `run.done`) ready to forward over SSE or WebSockets; `await run.result` gives the same result as `send()` ([docs/streaming.md](docs/streaming.md))
=======
- **Sessions** — `agent.session()` keeps a multi-turn conversation (in memory, or persisted with `FileSessionStore`)
- **Project instructions** — `createAgent({ projectInstructions: true })` appends the nearest `AGENTS.md` / `CLAUDE.md` to the instructions (opt-in)
>>>>>>> origin/main
- **Parallel tool calls** — when the model asks for several tools in one turn they run concurrently (cap it with `toolConcurrency`, or `1` for sequential), and results still reach the transcript in the model's call order
- **Skills** — `defineSkill()` / `loadSkills('./skills')`: only each skill's name and description sit in the system prompt; the model loads the full markdown on demand through an auto-registered `load_skill` tool
- **Multi-agent delegation** — wrap a child agent as a tool with `createDelegateTool()`, with a `maxDepth` guard against delegation loops
Expand Down Expand Up @@ -55,6 +59,17 @@ console.log(text);
`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENROUTER_API_KEY`,
`OLLAMA_BASE_URL` that is present.

Stream the reply token by token instead (see [docs/streaming.md](docs/streaming.md) for every event):

```typescript
import { createAgent } from '@loushy/build-ai-agent';

const agent = createAgent({ model: 'openai/gpt-4o-mini' });
for await (const event of agent.stream('Hello!')) {
if (event.type === 'text.delta') process.stdout.write(event.text);
}
```

When you need a custom provider (your own `LLMProvider`, a mock, extra
config), pass the instance instead:

Expand Down Expand Up @@ -235,6 +250,7 @@ console.log(session.id, session.messages.length);
- [Configuration](docs/configuration.md) - agent spec fields, provider env vars, `AgentExecutor.execute()` options, CLI flags
- [Deployment](docs/deployment.md) - `loushy build` targets: Node server, Docker, Cloudflare Workers
- [API Overview](docs/api-overview.md) - the main exports; `npm run docs:build` generates the full TypeDoc reference
- [Streaming](docs/streaming.md) - `agent.stream()`: the typed event schema, terminal and SSE examples
- [Workspace tools](docs/workspace-tools.md) - file system and shell tools for coding agents, and their security model
- [Tracing and observability](docs/observability.md) - OpenTelemetry GenAI spans, attribute table, content opt-in
- [Testing](docs/testing.md) - unit-test agents deterministically with the scripted `mockModel`
Expand Down Expand Up @@ -600,7 +616,7 @@ npm run pipeline:demo:trigger # POSTs a synthetic error to kick it off

### Core

- **`createAgent()`** - zero-config `{ send }` agent
- **`createAgent()`** - zero-config `{ send, stream }` agent
- **`AgentBuilder`** - fluent `AgentConfig` builder
- **`AgentExecutor`** - static executor (`execute()`, approvals, checkpoints, tracing)
- **`ToolRegistry`** - manage available tools
Expand Down
286 changes: 286 additions & 0 deletions docs/streaming.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,286 @@
# Streaming

`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`. Every event is a
plain JSON object, so you can forward it to a browser over Server-Sent Events
or a WebSocket without converting it.

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

const agent = createAgent({ model: 'openai/gpt-4o-mini' });

for await (const event of agent.stream('Weather in Paris?')) {
if (event.type === 'text.delta') process.stdout.write(event.text);
}
```

The full API has the same method: `AgentExecutor.stream(options)` takes the
same options as `AgentExecutor.execute()` (approval store, checkpoints, hooks,
tracing, `toolConcurrency`, ...).

## The `AgentRun` handle

`stream()` returns an `AgentRun`:

```ts no-verify
interface AgentRun extends AsyncIterable<AgentEvent> {
readonly runId: string; // the runId on every event
readonly result: Promise<ExecutionResult>; // what send() / execute() would return
}
```

- **The run starts immediately.** You do not have to iterate: `await run.result`
on its own drives the run to completion.
- **`result`** resolves with the same `ExecutionResult` that `send()` returns,
and rejects with the same error `send()` would reject with. An aborted run
resolves with `finishReason: 'aborted'`; a run paused for approval resolves
with `finishReason: 'awaiting-approval'` and `approvalId`. Leaving `result`
unawaited never causes an unhandled rejection.
- **Breaking out of the `for await` loop early aborts the run** (through the
same path as `signal`, see [Cancellation](#cancellation)). `result` then
resolves with `finishReason: 'aborted'`.
- **No backpressure; nothing is dropped.** The run never waits for the
consumer. Events are buffered until you read them, so a slow consumer sees
every event, in order. Iterating after the run finished still yields all of
its events.
- **One consumer.** An `AgentRun` can be iterated once; a second `for await`
throws. To fan out, collect the events yourself.
- Invalid options (no `provider`, `agent` or `input`, a bad `toolConcurrency`)
throw synchronously from `stream()`.

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

const agent = createAgent({ model: 'openai/gpt-4o-mini' });

const run = agent.stream('Summarize the report', { signal: AbortSignal.timeout(30_000) });
const result = await run.result; // no iteration needed
console.log(result.finishReason, result.text);
```

## Event schema (version 1)

Every event has these fields:

| Field | Type | Meaning |
| ----------- | -------- | ------- |
| `type` | string | The event type (table below). Narrow on it in TypeScript. |
| `runId` | string | Identifies the run. The same on every event of one `stream()` call. |
| `seq` | number | `0` for the first event, then `+1` per event, with no gaps. |
| `timestamp` | string | When the event was emitted, ISO 8601 (`2026-10-01T09:30:00.000Z`). |
| `v` | `1` | Schema version, exported as `AGENT_EVENT_SCHEMA_VERSION`. |

The event types and their extra fields:

| `type` | Extra fields | Emitted |
| -------------------- | ------------ | ------- |
| `run.start` | `agentName: string`, `agentId?: string` | First event of every run. |
| `step.start` | `step: number` | A model step begins: one model call plus the tool calls it asks for. `step` counts from 1 (a run resumed from a checkpoint continues the count). |
| `text.delta` | `text: string` | A chunk of model text, as it arrives. |
| `text.done` | `text: string` | The step's complete text: the concatenation of its `text.delta` events. Only for steps with text. |
| `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 `resumeAfterApproval()` (see the README). |
| `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. `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. |
| `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`; a failed run has `finishReason: 'error'`, `text: ''` and no `usage`. |

Optional fields are left out when they have no value. They are never
`undefined`, so `JSON.parse(JSON.stringify(event))` returns an equal object.

### Ordering guarantees

- `run.start` is first and `run.done` is last, each exactly once.
- Each `step.start` is followed by exactly one `step.done` with the same
`step`, before the next `step.start`. Everything a step does happens between
the two.
- Inside a step: `text.delta` events, then `text.done`, then the tool events.
- Tool calls of one step run in parallel (see `toolConcurrency`):
`tool.start` events come in the model's call order and `tool.done` /
`tool.error` events in completion order. Match them by `toolCallId`.
- When a call needs approval, the calls before it run and report, then
`approval.requested`, `step.done` (`'awaiting-approval'`) and `run.done`
(`'awaiting-approval'`) follow. Calls after it never start.

A text-only run:

```
run.start → step.start → text.delta × n → text.done → step.done → run.done
```

A run with one tool call:

```
run.start
step.start(1) → tool.start → tool.done → step.done(1, 'tool_calls')
step.start(2) → text.delta × n → text.done → step.done(2, 'stop')
run.done('stop')
```

### TypeScript

`AgentEvent` is a discriminated union: narrowing on `event.type` gives the
payload. Each event type is exported too (`TextDeltaEvent`, `ToolDoneEvent`,
`RunDoneEvent`, ...), and `AgentEventOf<'tool.done'>` picks one by name.

```ts
import { isAgentEvent, isToolEvent, type AgentEvent } from '@loushy/build-ai-agent';

function render(event: AgentEvent): string {
switch (event.type) {
case 'text.delta':
return event.text;
case 'tool.start':
return `\n[${event.toolName}] ${JSON.stringify(event.args)}\n`;
case 'tool.error':
return `\n[${event.toolName} failed: ${event.error.message}]\n`;
case 'run.done':
return `\n(${event.finishReason})\n`;
default:
return '';
}
}

// isToolEvent / isTextEvent / isStepEvent narrow to an event family:
const toolNames = (events: AgentEvent[]) => events.filter(isToolEvent).map((e) => e.toolName);

// isAgentEvent validates an event received over the wire:
const received: unknown = JSON.parse('{"type":"run.start","runId":"r","seq":0,"timestamp":"","v":1,"agentName":"a"}');
if (isAgentEvent(received)) console.log(render(received), toolNames([received]));
```

### Versioning

`v` changes only when an existing event changes incompatibly (a field
removed, renamed or retyped). New event types and new optional fields can be
added without changing `v`, so ignore event types you do not know.

## Token streaming and providers

When the provider implements `stream()` (all built-in providers and
`mockModel` do), each model step is streamed and `text.delta` events arrive
as the model produces text. Tool calls are assembled from the stream. When a
provider has no `stream()`, or its `supportsStreaming(model)` returns
`false`, the step falls back to `generate()` and its text arrives as a single
`text.delta` followed by `text.done`.

Streaming changes only how one model step is obtained. Hooks, argument
validation, approvals, parallel tool calls, checkpoints, cancellation,
tracing spans and the other `execute()` callbacks behave exactly as in
`send()`. `send()` and `execute()` themselves still use `generate()`.

A custom provider's `stream()` should yield `text-delta` chunks and a final
`finish` chunk with `finishReason` and `usage`. Tool calls can be yielded as
`tool-call` chunks, or resolved on the `toolCalls` promise of the
`StreamResult`. An `error` chunk fails the step.

## Cancellation

Pass `signal` to stop a run from outside, or break out of the loop. Both end
the run the same way as an aborted `send()`: the signal is checked between
stream chunks, before every model call and every tool call, and reaches the
provider and the tools. The stream ends with `step.done` (`'aborted'`, when a
step was running) and `run.done` (`'aborted'`), and `result` resolves with
`finishReason: 'aborted'`.

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

const agent = createAgent({ model: 'openai/gpt-4o-mini' });
const run = agent.stream('Write a long story');

let chars = 0;
for await (const event of run) {
if (event.type === 'text.delta') chars += event.text.length;
if (chars > 500) break; // aborts the run
}
console.log((await run.result).finishReason); // 'aborted'
```

## Example: terminal

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

const getWeather = defineTool({
name: 'get_weather',
description: 'Current weather for a city',
input: z.object({ city: z.string() }),
execute: async ({ city }) => ({ city, tempC: 21 }),
});

const agent = createAgent({ model: 'openai/gpt-4o-mini', tools: [getWeather] });

for await (const event of agent.stream('Weather in Paris?')) {
switch (event.type) {
case 'text.delta':
process.stdout.write(event.text);
break;
case 'tool.start':
console.log(`\n→ ${event.toolName}(${JSON.stringify(event.args)})`);
break;
case 'tool.done':
console.log(`← ${event.toolName} in ${event.durationMs}ms`);
break;
case 'run.done':
console.log(`\n[${event.finishReason}, ${event.usage?.totalTokens ?? 0} tokens]`);
break;
}
}
```

## Example: Server-Sent Events

Events are JSON-serializable, so an SSE endpoint is one `res.write` per
event. Abort the run when the client disconnects.

```ts
import { createServer } from 'node:http';
import { createAgent } from '@loushy/build-ai-agent';

const agent = createAgent({ model: 'openai/gpt-4o-mini' });

createServer(async (req, res) => {
const question = new URL(req.url ?? '/', 'http://localhost').searchParams.get('q') ?? 'Hello!';
const controller = new AbortController();
res.on('close', () => controller.abort());

res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' });
for await (const event of agent.stream(question, { signal: controller.signal })) {
res.write(`data: ${JSON.stringify(event)}\n\n`);
}
res.end();
}).listen(3000);
```

In the browser:

```ts no-verify
const source = new EventSource('/chat?q=Weather%20in%20Paris%3F');
source.onmessage = (message) => {
const event = JSON.parse(message.data);
if (event.type === 'text.delta') output.textContent += event.text;
if (event.type === 'run.done') source.close();
};
```

## Example: the full API

`AgentExecutor.stream()` accepts every `execute()` option. Here a tool that
needs approval ends the stream with `approval.requested`:

```ts
import { AgentExecutor } from '@loushy/build-ai-agent';

const run = AgentExecutor.stream({ agent, input, provider, toolRegistry, approvalStore });
for await (const event of run) {
if (event.type === 'approval.requested') {
console.log(`Approve ${event.toolName}(${JSON.stringify(event.args)})? id=${event.approvalId}`);
}
}
const paused = await run.result; // paused.finishReason === 'awaiting-approval'
```
Loading
Loading