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
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] - 2026-09-28

### Added
- Background sub-agents end with the lead run (LOU-Y4.2): when a run ends (any `finishReason`, or an error), its background sub-agents still queued or running are cancelled, and the run resolves once their runs have stopped, so none of their events reaches `onEvent` afterwards. `withSubagentOptions(subagents, { awaitBackgroundOnFinish: true })` waits for them instead (an aborted or failed lead still cancels them). The new `result.backgroundTasks` lists the run's background tasks as `{ taskId, agent, status, elapsedMs, ... }` (absent when it started none). `createAgent({ subagentOptions: { maxConcurrent, awaitBackgroundOnFinish } })` sets the same options (over any attached with `withSubagentOptions()`, without changing the `subagents` value). The executor hook behind it is public: `ExecuteOptions.onRunEnd({ result } | { error })` is called exactly once per `execute()` / `stream()` run, however it ends; an error it throws rejects the run. See docs/sub-agents.md.
- 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.
- 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: 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.
- `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.
Expand All @@ -33,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- README revamped into a short front page (LOU-D52); the details it dropped moved to `docs/` (new pages: tools, approvals, providers, cli, flows, guardrails, utilities).

### Changed
- Background sub-agents still running when the lead run ends are now cancelled (LOU-Y4.2) instead of running to completion on their own; a lead run that pauses for approval cancels them too (the resumed run starts with none). **Migration:** to keep them, have the lead collect them with `agent_await` before its final answer, or set `subagentOptions: { awaitBackgroundOnFinish: true }`.
- Heavy dependencies are optional peers (LOU-D40): `dockerode` (Docker sandboxing, `SubprocessSandbox`), `@modelcontextprotocol/sdk` (`serveMcp`, `loushy mcp`, MCP clients) and `prompts` (the interactive questions of `loushy init`) moved from `dependencies` to `peerDependencies`, marked optional in `peerDependenciesMeta`. They were already loaded lazily; now a project that uses none of them does not install them. Using one without its package fails that call with `MissingPeerDependencyError`, which now also carries `feature` and names the exact command (for example `npm install dockerode@^5.0.1`). `loushy doctor` lists the three with what they enable (a missing `dockerode` fails the check when the agent spec uses a sandboxed tool), and its version lookup no longer returns `undefined` for packages whose `exports` map hides `package.json`. **Migration:** install the peer if you use Docker sandboxing (`npm install dockerode`), MCP (`npm install @modelcontextprotocol/sdk`), or interactive `loushy init` (`npm install prompts`; `npm create loushy-agent` already does, and `loushy init --yes` needs nothing). See docs/installation.md.
- The `http` tool no longer loads `undici` for default requests (LOU-D40): with TLS verification on (the default) it uses the runtime's global `fetch`; `undici` (still a regular dependency) is loaded only for `validateSSL: false`, whose per-request TLS dispatcher the global `fetch` cannot express. `yaml` stays a dependency (agent specs and skills need a YAML parser).
- Retries no longer stack (LOU-V7.2): the built-in providers (`OpenAIProvider`, `AnthropicProvider`, `OllamaProvider`, `OpenRouterProvider`) now pass `maxRetries` to the `ai` SDK calls: `config.maxRetries`, or 2 (the `ai` SDK's default, so behaviour is unchanged) when unset. `createAgent()` builds the providers it resolves from strings with `maxRetries: 0` and retries in its `withRetry()` wrapper instead, so retries happen in one place (at most 3 calls by default, as before) and each one is observable. If you wrap a built-in provider in `withRetry()` / `resilientProvider()` yourself, build it with `maxRetries: 0`; note that a `maxRetries` you already set in a provider config is now honoured by the `ai` SDK.
Expand Down
62 changes: 49 additions & 13 deletions docs/sub-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,41 +101,77 @@ more tasks) while it runs. The lead then uses:

At most `maxConcurrent` background sub-agents of one lead run run at once
(default 3); further `background: true` calls get `status: 'queued'` and start
as slots free. Set it with `withSubagentOptions()`, which returns the same
`subagents` value:
as slots free. Set it with `subagentOptions` on `createAgent()`:

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

const researcher = createAgent({ provider, instructions: 'You research.', description: 'Finds and summarizes sources' });

const lead = createAgent({
provider,
instructions: 'You coordinate research.',
subagents: withSubagentOptions({ researcher }, { maxConcurrent: 2 }),
subagents: { researcher },
subagentOptions: { maxConcurrent: 2, awaitBackgroundOnFinish: true },
});
```

For `AgentExecutor.execute()`, attach the same options to the `subagents`
value with `withSubagentOptions(subagents, options)`, which returns it. On
`createAgent()`, `subagentOptions` override options attached that way.

A background sub-agent inherits from the lead run like a synchronous one
(hooks, tracing, `onEvent`, usage roll-up), and `maxSubagentDepth` applies the
same way. Aborting the lead's signal cancels its background sub-agents, queued
or running.

Limits (follow-up LOU-Y4.2, which needs a run-end hook in the executor):
### When the lead run ends

Background tasks belong to one run (one `execute()` / `send()` / `stream()`).
When that run ends, however it ends, the tasks still queued or running are
**cancelled** by default (their runs are aborted). With
`awaitBackgroundOnFinish: true` the run instead waits for them to finish
before it resolves (an aborted or failed lead run still cancels them). Either
way the run resolves only once their runs have stopped, so no sub-agent event
reaches `onEvent` after it; with `awaitBackgroundOnFinish` their events arrive
after the lead's own `finish` event.

The result lists every background task of the run with its final status:

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

const researcher = createAgent({ provider, instructions: 'You research.', description: 'Finds and summarizes sources' });
const lead = createAgent({ provider, instructions: 'You coordinate research.', subagents: { researcher } });

const result = await lead.send('Research three topics in the background.');
for (const task of result.backgroundTasks ?? []) {
console.log(task.taskId, task.agent, task.status); // e.g. task_1 researcher cancelled
}
```

`result.backgroundTasks` holds `{ taskId, agent, status, elapsedMs, error?,
approvalId?, toolName? }` per task (the `agent_status` shape) and is absent
when the run started none. Use `agent_await` in the lead (the system prompt
tells it to) to get their answers into the conversation.

The executor hook behind this is public: `ExecuteOptions.onRunEnd` is called
exactly once per run, with `{ result }` (whatever the `finishReason`) or
`{ error }`; the run settles after it returns. Background tasks are reported
on `result` before your `onRunEnd` sees it.

Limits:

- Background tasks belong to one `execute()` call. When the lead's run
finishes, sub-agents still running are **not** cancelled or awaited yet:
they run to completion on their own, and their events still reach
`onEvent`. Have the lead collect them with `agent_await` before its final
answer (the system prompt tells it to), or abort the signal you passed to
the run to stop them.
- A lead run that pauses for approval (`finishReason: 'awaiting-approval'`)
ends there: its background tasks are cancelled (or awaited) like at any
other end, and the resumed run starts with none. The resumed run is a
separate run with no link to the paused one's tasks (it may even run in
another process), so they cannot be kept alive across the pause.
- A background sub-agent that needs approval stops with
`status: 'awaiting-approval'` (with the child's `approvalId` and
`toolName`); its call does not run, and it cannot be resumed through the
approval store. Run that task synchronously (`background: false`) when it
may need approval.
- A lead that pauses for approval and is resumed starts with no background
tasks: those started before the pause cannot be observed afterwards.

## Dynamic catalogs

Expand Down
62 changes: 49 additions & 13 deletions llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4677,41 +4677,77 @@ more tasks) while it runs. The lead then uses:

At most `maxConcurrent` background sub-agents of one lead run run at once
(default 3); further `background: true` calls get `status: 'queued'` and start
as slots free. Set it with `withSubagentOptions()`, which returns the same
`subagents` value:
as slots free. Set it with `subagentOptions` on `createAgent()`:

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

const researcher = createAgent({ provider, instructions: 'You research.', description: 'Finds and summarizes sources' });

const lead = createAgent({
provider,
instructions: 'You coordinate research.',
subagents: withSubagentOptions({ researcher }, { maxConcurrent: 2 }),
subagents: { researcher },
subagentOptions: { maxConcurrent: 2, awaitBackgroundOnFinish: true },
});
```

For `AgentExecutor.execute()`, attach the same options to the `subagents`
value with `withSubagentOptions(subagents, options)`, which returns it. On
`createAgent()`, `subagentOptions` override options attached that way.

A background sub-agent inherits from the lead run like a synchronous one
(hooks, tracing, `onEvent`, usage roll-up), and `maxSubagentDepth` applies the
same way. Aborting the lead's signal cancels its background sub-agents, queued
or running.

Limits (follow-up LOU-Y4.2, which needs a run-end hook in the executor):
### When the lead run ends

Background tasks belong to one run (one `execute()` / `send()` / `stream()`).
When that run ends, however it ends, the tasks still queued or running are
**cancelled** by default (their runs are aborted). With
`awaitBackgroundOnFinish: true` the run instead waits for them to finish
before it resolves (an aborted or failed lead run still cancels them). Either
way the run resolves only once their runs have stopped, so no sub-agent event
reaches `onEvent` after it; with `awaitBackgroundOnFinish` their events arrive
after the lead's own `finish` event.

The result lists every background task of the run with its final status:

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

const researcher = createAgent({ provider, instructions: 'You research.', description: 'Finds and summarizes sources' });
const lead = createAgent({ provider, instructions: 'You coordinate research.', subagents: { researcher } });

const result = await lead.send('Research three topics in the background.');
for (const task of result.backgroundTasks ?? []) {
console.log(task.taskId, task.agent, task.status); // e.g. task_1 researcher cancelled
}
```

`result.backgroundTasks` holds `{ taskId, agent, status, elapsedMs, error?,
approvalId?, toolName? }` per task (the `agent_status` shape) and is absent
when the run started none. Use `agent_await` in the lead (the system prompt
tells it to) to get their answers into the conversation.

The executor hook behind this is public: `ExecuteOptions.onRunEnd` is called
exactly once per run, with `{ result }` (whatever the `finishReason`) or
`{ error }`; the run settles after it returns. Background tasks are reported
on `result` before your `onRunEnd` sees it.

Limits:

- Background tasks belong to one `execute()` call. When the lead's run
finishes, sub-agents still running are **not** cancelled or awaited yet:
they run to completion on their own, and their events still reach
`onEvent`. Have the lead collect them with `agent_await` before its final
answer (the system prompt tells it to), or abort the signal you passed to
the run to stop them.
- A lead run that pauses for approval (`finishReason: 'awaiting-approval'`)
ends there: its background tasks are cancelled (or awaited) like at any
other end, and the resumed run starts with none. The resumed run is a
separate run with no link to the paused one's tasks (it may even run in
another process), so they cannot be kept alive across the pause.
- A background sub-agent that needs approval stops with
`status: 'awaiting-approval'` (with the child's `approvalId` and
`toolName`); its call does not run, and it cannot be resumed through the
approval store. Run that task synchronously (`background: false`) when it
may need approval.
- A lead that pauses for approval and is resumed starts with no background
tasks: those started before the pause cannot be observed afterwards.

## Dynamic catalogs

Expand Down
Loading
Loading