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
- Durable sessions (LOU-W9): `agent.session({ id, store, checkpointStore })` (or a `store` that carries both, `{ sessions, checkpoints }`, such as a `SqliteStore`) runs each turn with `sessionId: '<id>.turn-<n>'` and that `CheckpointStore`, so it is checkpointed after every model response and tool result. `session.resume()` finishes a turn interrupted by a crash, a failed checkpoint write or a `PropagatingToolError` (also in a new process) without re-running recorded tool calls or model responses, adds it to the transcript as `send()` would and returns its result (`null` when nothing is pending); `session.pending()` reports an unfinished turn and `session.discardPending()` drops it. `send()` / `stream()` resume a pending turn before sending the new message. A turn paused for approval stays in its checkpoint until it finishes: `resume()` / `send()` throw `SessionAwaitingApprovalError`, and `agent.approvals.resolve()` continues it in the session. Sessions without a checkpoint store are unchanged. See docs/sessions.md.
- 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.
Expand Down
23 changes: 8 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,24 +146,17 @@ if (run.finishReason === 'awaiting-approval') {
### Durable sessions with SQLite

```ts
import { AgentBuilder, AgentExecutor, createAgent, resolveProvider } from '@loushy/build-ai-agent';
import { createAgent, resolveProvider } from '@loushy/build-ai-agent';
import { SqliteStore } from '@loushy/build-ai-agent/sqlite';

const store = new SqliteStore('./.loushy/agent.db'); // sessions, checkpoints and approvals in one file
const provider = resolveProvider('openai/gpt-4o-mini');

// A conversation that survives restarts: same id + same file = same conversation.
const agent = createAgent({ provider, approvalStore: store.approvals });
await agent.session({ id: 'user-42', store: store.sessions }).send('My name is Ali.');
const { text } = await agent.session({ id: 'user-42', store: store.sessions }).send('What is my name?'); // "Ali"

// A crash-safe run: checkpointed after every model response and tool result.
// Calling execute() again with the same sessionId resumes it instead of starting over.
const booker = AgentBuilder.create().setName('booker').setPrompt('You book restaurant tables.').build();
await AgentExecutor.execute({
agent: booker, input: 'Book a table for 2 tonight', provider,
sessionId: 'booking-7', checkpointStore: store.checkpoints,
});
const agent = createAgent({ provider: resolveProvider('openai/gpt-4o-mini'), approvalStore: store.approvals });

// Same id + same file = same conversation, checkpointed after every model response and tool result.
await agent.session({ id: 'user-42', store }).send('My name is Ali.');
const session = agent.session({ id: 'user-42', store });
await session.resume(); // after a crash: finishes the interrupted turn without redoing finished steps
const { text } = await session.send('What is my name?'); // "Ali"
```

## Documentation
Expand Down
4 changes: 4 additions & 0 deletions docs/durable-execution.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ await AgentExecutor.execute({ agent, input: 'Book a table for 2 tonight', provid
await AgentExecutor.execute({ agent, input: 'Make it 3 people', provider, sessionId: 'chat-42', checkpointStore: checkpoints });
```

With `createAgent()`, a session does this for you: `agent.session({ id, store, checkpointStore })`
checkpoints every turn and `session.resume()` finishes an interrupted one - see
[Durable sessions](./sessions.md#durable-sessions).

## What is checkpointed, and when

A checkpoint is the run's whole transcript plus its step count, usage,
Expand Down
59 changes: 53 additions & 6 deletions docs/sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ console.log(session.id, session.messages.length);
| `messages` | A read-only snapshot of the transcript (user, assistant and tool messages; no system prompt). Editing the snapshot does not change the session. |
| `load()` | Reads the saved transcript from the store. `send()` does this for you; call it to show history before the first `send()` of a resumed session. |
| `clear()` | Forgets the conversation, including in the store. |
| `pending()` | In a [durable session](#durable-sessions), the turn that has not finished (`{ status, approvalId? }`), or `null`. |
| `resume({ signal })` | In a durable session, finishes an interrupted turn and resolves with its result, or `null` when none is pending. |
| `discardPending()` | In a durable session, drops an unfinished turn without running it. |

Concurrent `send()` calls on one session are queued and run one after another in
call order, so the transcript never interleaves.
Expand Down Expand Up @@ -81,11 +84,55 @@ const { text } = await session.send('What is my name?');
to streams, as with `agent.stream()`.

Sessions are a thin layer: the session owns the transcript and hands it to the
executor on each turn. They do not use the checkpoint/resume mechanism
(`sessionId` + `checkpointStore` on `AgentExecutor.execute()`). That lower-level
mechanism also continues a finished conversation, and additionally resumes a
run interrupted mid-turn (a crash, an abort, an approval pause) without
re-running finished tools - see [Durable execution](./durable-execution.md).
executor on each turn. Without a checkpoint store, a turn is saved only when it
ends, so a crash in the middle of a turn loses it; see the next section.

## Durable sessions

Give the session a `CheckpointStore` and every turn is checkpointed after each
model response and tool result, using the [durable execution](./durable-execution.md)
mechanism. A turn interrupted by a crash, a failed checkpoint write or a
`PropagatingToolError` can then be finished later, in another process:

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

declare const checkpointStore: CheckpointStore; // e.g. LocalStorageCheckpointStore, or SqliteStore's `checkpoints`

const agent = createAgent({ provider });
const store = new FileSessionStore('./.loushy/sessions');

// After a restart: finish the turn that was running, if any.
const session = agent.session({ id: 'user-42', store, checkpointStore });
const finished = await session.resume(); // ExecutionResult, or null when nothing was pending
console.log(finished?.text, await session.pending()); // pending() is null now
```

- Pass `checkpointStore`, or a `store` that carries both stores: any
`{ sessions, checkpoints }` object, such as a `SqliteStore`
(`agent.session({ id, store: sqliteStore })`).
- Each turn runs with `sessionId: '<session id>.turn-<n>'` (`n` is the length
of the transcript when the turn started), so a new process finds the
interrupted turn without any extra bookkeeping. A finished turn joins the
transcript, exactly as a plain `send()` does, and its checkpoint is deleted.
- `resume()` continues the turn through the executor's resume path
(`input: []`): tool calls whose results were recorded do not run again, and
a recorded model response is not requested again. A tool that was running
when the process died does run again (at-least-once, see
[Durable execution](./durable-execution.md#at-least-once-tools-make-side-effects-idempotent)).
- `send()` and `stream()` resume a pending turn first, then send the new
message, so the model sees the finished turn (the resumed turn's events are
not streamed). Use `pending()` to check first, or `discardPending()` to drop
the unfinished turn instead.
- A turn that pauses on a `needsApproval` tool stays in its checkpoint, not in
the transcript, until it finishes. While it waits, `resume()`, `send()` and
`stream()` throw `SessionAwaitingApprovalError` (with its `approvalId`), and
`agent.approvals.resolve({ id, approved })` continues the turn in this
session. After a restart, open the session and call `resume()` (or `send()`)
once before resolving, so the agent knows which session the approval belongs
to; give both agents the same durable `approvalStore`.
- An aborted turn is dropped (its checkpoint is deleted), as without a
checkpoint store. `clear()` deletes a pending turn too.

## Stores

Expand Down Expand Up @@ -146,7 +193,7 @@ import { createAgent, AgentExecutor } from '@loushy/build-ai-agent';
import { SqliteStore } from '@loushy/build-ai-agent/sqlite';

const store = new SqliteStore('./.loushy/agent.db'); // or ':memory:'
const session = createAgent({ provider }).session({ id: 'user-42', store: store.sessions });
const session = createAgent({ provider }).session({ id: 'user-42', store }); // transcript + per-step checkpoints
await session.send('Hello');

// Same file, same store, for durable runs with approvals:
Expand Down
86 changes: 65 additions & 21 deletions llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -145,24 +145,17 @@ if (run.finishReason === 'awaiting-approval') {
### Durable sessions with SQLite

```ts
import { AgentBuilder, AgentExecutor, createAgent, resolveProvider } from '@loushy/build-ai-agent';
import { createAgent, resolveProvider } from '@loushy/build-ai-agent';
import { SqliteStore } from '@loushy/build-ai-agent/sqlite';

const store = new SqliteStore('./.loushy/agent.db'); // sessions, checkpoints and approvals in one file
const provider = resolveProvider('openai/gpt-4o-mini');

// A conversation that survives restarts: same id + same file = same conversation.
const agent = createAgent({ provider, approvalStore: store.approvals });
await agent.session({ id: 'user-42', store: store.sessions }).send('My name is Ali.');
const { text } = await agent.session({ id: 'user-42', store: store.sessions }).send('What is my name?'); // "Ali"

// A crash-safe run: checkpointed after every model response and tool result.
// Calling execute() again with the same sessionId resumes it instead of starting over.
const booker = AgentBuilder.create().setName('booker').setPrompt('You book restaurant tables.').build();
await AgentExecutor.execute({
agent: booker, input: 'Book a table for 2 tonight', provider,
sessionId: 'booking-7', checkpointStore: store.checkpoints,
});
const agent = createAgent({ provider: resolveProvider('openai/gpt-4o-mini'), approvalStore: store.approvals });

// Same id + same file = same conversation, checkpointed after every model response and tool result.
await agent.session({ id: 'user-42', store }).send('My name is Ali.');
const session = agent.session({ id: 'user-42', store });
await session.resume(); // after a crash: finishes the interrupted turn without redoing finished steps
const { text } = await session.send('What is my name?'); // "Ali"
```

## Documentation
Expand Down Expand Up @@ -2785,6 +2778,10 @@ await AgentExecutor.execute({ agent, input: 'Book a table for 2 tonight', provid
await AgentExecutor.execute({ agent, input: 'Make it 3 people', provider, sessionId: 'chat-42', checkpointStore: checkpoints });
```

With `createAgent()`, a session does this for you: `agent.session({ id, store, checkpointStore })`
checkpoints every turn and `session.resume()` finishes an interrupted one - see
[Durable sessions](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/sessions.md#durable-sessions).

## What is checkpointed, and when

A checkpoint is the run's whole transcript plus its step count, usage,
Expand Down Expand Up @@ -3835,6 +3832,9 @@ console.log(session.id, session.messages.length);
| `messages` | A read-only snapshot of the transcript (user, assistant and tool messages; no system prompt). Editing the snapshot does not change the session. |
| `load()` | Reads the saved transcript from the store. `send()` does this for you; call it to show history before the first `send()` of a resumed session. |
| `clear()` | Forgets the conversation, including in the store. |
| `pending()` | In a [durable session](#durable-sessions), the turn that has not finished (`{ status, approvalId? }`), or `null`. |
| `resume({ signal })` | In a durable session, finishes an interrupted turn and resolves with its result, or `null` when none is pending. |
| `discardPending()` | In a durable session, drops an unfinished turn without running it. |

Concurrent `send()` calls on one session are queued and run one after another in
call order, so the transcript never interleaves.
Expand Down Expand Up @@ -3890,11 +3890,55 @@ const { text } = await session.send('What is my name?');
to streams, as with `agent.stream()`.

Sessions are a thin layer: the session owns the transcript and hands it to the
executor on each turn. They do not use the checkpoint/resume mechanism
(`sessionId` + `checkpointStore` on `AgentExecutor.execute()`). That lower-level
mechanism also continues a finished conversation, and additionally resumes a
run interrupted mid-turn (a crash, an abort, an approval pause) without
re-running finished tools - see [Durable execution](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/durable-execution.md).
executor on each turn. Without a checkpoint store, a turn is saved only when it
ends, so a crash in the middle of a turn loses it; see the next section.

## Durable sessions

Give the session a `CheckpointStore` and every turn is checkpointed after each
model response and tool result, using the [durable execution](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/durable-execution.md)
mechanism. A turn interrupted by a crash, a failed checkpoint write or a
`PropagatingToolError` can then be finished later, in another process:

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

declare const checkpointStore: CheckpointStore; // e.g. LocalStorageCheckpointStore, or SqliteStore's `checkpoints`

const agent = createAgent({ provider });
const store = new FileSessionStore('./.loushy/sessions');

// After a restart: finish the turn that was running, if any.
const session = agent.session({ id: 'user-42', store, checkpointStore });
const finished = await session.resume(); // ExecutionResult, or null when nothing was pending
console.log(finished?.text, await session.pending()); // pending() is null now
```

- Pass `checkpointStore`, or a `store` that carries both stores: any
`{ sessions, checkpoints }` object, such as a `SqliteStore`
(`agent.session({ id, store: sqliteStore })`).
- Each turn runs with `sessionId: '<session id>.turn-<n>'` (`n` is the length
of the transcript when the turn started), so a new process finds the
interrupted turn without any extra bookkeeping. A finished turn joins the
transcript, exactly as a plain `send()` does, and its checkpoint is deleted.
- `resume()` continues the turn through the executor's resume path
(`input: []`): tool calls whose results were recorded do not run again, and
a recorded model response is not requested again. A tool that was running
when the process died does run again (at-least-once, see
[Durable execution](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/durable-execution.md#at-least-once-tools-make-side-effects-idempotent)).
- `send()` and `stream()` resume a pending turn first, then send the new
message, so the model sees the finished turn (the resumed turn's events are
not streamed). Use `pending()` to check first, or `discardPending()` to drop
the unfinished turn instead.
- A turn that pauses on a `needsApproval` tool stays in its checkpoint, not in
the transcript, until it finishes. While it waits, `resume()`, `send()` and
`stream()` throw `SessionAwaitingApprovalError` (with its `approvalId`), and
`agent.approvals.resolve({ id, approved })` continues the turn in this
session. After a restart, open the session and call `resume()` (or `send()`)
once before resolving, so the agent knows which session the approval belongs
to; give both agents the same durable `approvalStore`.
- An aborted turn is dropped (its checkpoint is deleted), as without a
checkpoint store. `clear()` deletes a pending turn too.

## Stores

Expand Down Expand Up @@ -3955,7 +3999,7 @@ import { createAgent, AgentExecutor } from '@loushy/build-ai-agent';
import { SqliteStore } from '@loushy/build-ai-agent/sqlite';

const store = new SqliteStore('./.loushy/agent.db'); // or ':memory:'
const session = createAgent({ provider }).session({ id: 'user-42', store: store.sessions });
const session = createAgent({ provider }).session({ id: 'user-42', store }); // transcript + per-step checkpoints
await session.send('Hello');

// Same file, same store, for durable runs with approvals:
Expand Down
Loading
Loading