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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,12 @@ opt-out.
- `lousho doctor --ping` checks OpenRouter at `/api/v1/key`, which needs the key (its `/models` answers without one, so a wrong key passed); a key the provider rejects is now a failure, and a ping of a custom `OPENAI_BASE_URL` says it shows reachability only.
- `lousho doctor` fix lines use PowerShell syntax (`$env:NAME="<your key>"`) on Windows.
- The `lousho eval` table shows the scores of passing scored gates (such as `t.check('words', n, atLeast(3))`), not only in `--json`.
- **Behavior change.** An agent with `output` and tools no longer sends the strict `responseFormat` schema on calls that offer tools: `openai/gpt-4o-mini` via OpenRouter then called tools on every step until `maxSteps` and never answered. `responseFormat` now goes only on calls without tools (an agent without tools, the "answer now" call at `maxSteps`, the repair step); tool-offering calls answer from the schema in the prompt, with the existing parse and repair. `output: { schema, responseFormat: 'always' }` restores the old behavior, and is the default with `promptSchema: false`. `object.delta` events still stream on every step of an `output` run. See [Structured output](docs/structured-output.md#how-it-works).

### Documentation

- Tool idempotency: `toolCallId` and `ctx.approval.id` identify one call; after a crash the model may retry with a new call, so side effects that must happen once need a key from your own data. New guidance for ending a run after a rejected approval, and for extract-then-approve-then-act with structured output.
- [Durable execution](docs/durable-execution.md#recovering-after-a-crash): `agent.resume()` resolves with a result, not a stream (stream a resumed run with `agent.stream([], { sessionId })`), and `agent.pending()` also lists runs still active in another process. [Installation](docs/installation.md#typescript-settings): TypeScript 6+ projects need `"types": ["node"]` for the SDK's declarations.

## [1.0.0-alpha.23] - 2026-10-16

Expand Down
1 change: 1 addition & 0 deletions api/executor.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1539,6 +1539,7 @@ interface OutputError {
// @public
interface OutputSpec<TSchema extends StandardSchemaV1 = StandardSchemaV1> {
promptSchema?: boolean;
responseFormat?: 'final' | 'always';
schema: TSchema;
}

Expand Down
1 change: 1 addition & 0 deletions api/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -4442,6 +4442,7 @@ export interface OutputError {
// @public
export interface OutputSpec<TSchema extends StandardSchemaV1 = StandardSchemaV1> {
promptSchema?: boolean;
responseFormat?: 'final' | 'always';
schema: TSchema;
}

Expand Down
1 change: 1 addition & 0 deletions api/worker.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -2604,6 +2604,7 @@ interface OutputError {
// @public
interface OutputSpec<TSchema extends StandardSchemaV1 = StandardSchemaV1> {
promptSchema?: boolean;
responseFormat?: 'final' | 'always';
schema: TSchema;
}

Expand Down
13 changes: 13 additions & 0 deletions docs/durable-execution.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,19 @@ for (const run of await agent.pending()) {
}
```

`agent.resume()` resolves with the finished `ExecutionResult` (or `null` when
nothing was pending); it does not return a stream. To watch a resumed run's
events, stream it instead: `agent.stream([], { sessionId })` resumes an
unfinished `kind: 'run'` checkpoint without new input (see
[above](#what-happens-when-you-send-again-with-the-same-sessionid)) and
returns the usual `AgentRun`. A paused run streams with
`agent.approvals.streamResolve()`.

`pending()` reads the shared checkpoint store, so it also lists runs that
are still running right now in another process or replica (as
`'in-progress'`, without `lastError`) - not only crashed ones. See below for
why resuming one of those is safe.

Each entry is `{ sessionId, kind, checkpointId, status, approvalId?,
approvalKind?, step, lastError?, attempts? }`:

Expand Down
15 changes: 15 additions & 0 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,21 @@ code import those types (error TS1479 inside the SDK's declarations). Use
add `"type": "module"`, or keep `skipLibCheck: true` (what `lousho init`
projects use).

The root entry's declarations use Node types (`Buffer`, and imports from
`node:crypto`, `node:http` and `node:net`), so install `@types/node`.
TypeScript 6 no longer loads every `@types` package by default (`types`
defaults to `[]`), so on TypeScript 6+ also list it in `tsconfig.json`,
or `skipLibCheck: false` reports `Cannot find name 'Buffer'` and
`Cannot find module 'node:http'` inside the SDK's declarations:

```json
{
"compilerOptions": {
"types": ["node"]
}
}
```

## Installing from a local build

To try an unreleased commit, build and pack the SDK from a checkout of this
Expand Down
41 changes: 30 additions & 11 deletions docs/structured-output.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,10 +176,18 @@ console.log(object?.note); // typed: string | undefined

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.
2. Model calls that offer no tools carry `responseFormat: { type: 'json',
schema }` on `GenerateOptions`: every call of an agent without tools, the
"answer now" call when steps run out (see below), and the repair step. 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; OpenRouter
and the `pi` provider send a strict `json_schema` response format); a
custom provider may use it or ignore it. A call that offers tools goes
without it, and the model answers from the schema in the prompt: with a
strict response format next to tools, some models (`openai/gpt-4o-mini`
via OpenRouter) keep calling tools until `maxSteps` instead of answering.
`toolChoice: 'none'` counts as no tools offered. To send it on every
call anyway, pass `output: { schema, responseFormat: 'always' }`.
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. A near miss is read without a repair call: when the whole
Expand Down Expand Up @@ -221,13 +229,15 @@ call only and is not kept in `result.messages` or the session transcript.

## The schema in the prompt

Steps 1 and 2 send the schema twice: once inside the `## Output format`
system-prompt block, and once on `responseFormat`. When the provider enforces
`responseFormat` (OpenAI structured outputs, OpenRouter, the `pi` provider)
the prompt copy is redundant - on a large schema it costs around a thousand
tokens per call. Pass `output` as `{ schema, promptSchema: false }` to leave
the schema out of the prompt; the JSON-only ask stays and `responseFormat` is
sent unchanged:
Steps 1 and 2 send the schema twice on a call without tools: once inside the
`## Output format` system-prompt block, and once on `responseFormat`. When the
provider enforces `responseFormat` (OpenAI structured outputs, OpenRouter, the
`pi` provider) the prompt copy is redundant - on a large schema it costs
around a thousand tokens per call. Pass `output` as `{ schema, promptSchema:
false }` to leave the schema out of the prompt; the JSON-only ask stays, and
since `responseFormat` is then the only copy of the schema it goes on every
call, tool-offering ones included (`responseFormat` defaults to `'always'`
here; set `'final'` to keep it off those calls):

```ts
import { z } from 'zod';
Expand All @@ -245,6 +255,15 @@ provider - or model - treats `responseFormat` as a JSON-mode hint only (the
built-in `ai`-SDK providers drop the schema for models without
structured-outputs support; a custom provider may ignore it entirely). The
prompt copy is then the only place the model sees the shape to answer with.
Keep it too for an agent with tools: its tool-offering calls carry no
`responseFormat` by default, so the prompt copy is what they answer from.

| `output` | Schema in the prompt | `responseFormat` on calls offering tools | on calls without tools |
| --- | --- | --- | --- |
| `schema` or `{ schema }` | yes | no | yes |
| `{ schema, responseFormat: 'always' }` | yes | yes | yes |
| `{ schema, promptSchema: false }` | no | yes | yes |
| `{ schema, promptSchema: false, responseFormat: 'final' }` | no | no | yes |

## Repaired output and business rules

Expand Down
1 change: 1 addition & 0 deletions docs/upgrading.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ there is one. A change marked "no opt-out" closed a security or data-loss bug.
| An aborted run ends `finishReason: 'aborted'` at once, even when the provider ignores the signal. See [Cancellation](./runs.md#cancellation). | no opt-out |
| The `openrouter` provider created without `apiKey` reads `OPENROUTER_API_KEY` (it sent `OPENAI_API_KEY`). | pass `apiKey` |
| The `@modelcontextprotocol/sdk` peer range is `^1.31.0`. | no opt-out |
| With `output`, `responseFormat` is sent only on model calls that offer no tools (an agent without tools, the "answer now" call at `maxSteps`, the repair step); a call that offers tools relies on the schema in the prompt. A strict `json_schema` next to tools made `openai/gpt-4o-mini` via OpenRouter call tools until `maxSteps`. See [How it works](./structured-output.md#how-it-works). | `output: { schema, responseFormat: 'always' }` (already the default with `promptSchema: false`) |

### Tools, MCP and tracing

Expand Down
70 changes: 59 additions & 11 deletions llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,21 @@ code import those types (error TS1479 inside the SDK's declarations). Use
add `"type": "module"`, or keep `skipLibCheck: true` (what `lousho init`
projects use).

The root entry's declarations use Node types (`Buffer`, and imports from
`node:crypto`, `node:http` and `node:net`), so install `@types/node`.
TypeScript 6 no longer loads every `@types` package by default (`types`
defaults to `[]`), so on TypeScript 6+ also list it in `tsconfig.json`,
or `skipLibCheck: false` reports `Cannot find name 'Buffer'` and
`Cannot find module 'node:http'` inside the SDK's declarations:

```json
{
"compilerOptions": {
"types": ["node"]
}
}
```

## Installing from a local build

To try an unreleased commit, build and pack the SDK from a checkout of this
Expand Down Expand Up @@ -7389,6 +7404,19 @@ for (const run of await agent.pending()) {
}
```

`agent.resume()` resolves with the finished `ExecutionResult` (or `null` when
nothing was pending); it does not return a stream. To watch a resumed run's
events, stream it instead: `agent.stream([], { sessionId })` resumes an
unfinished `kind: 'run'` checkpoint without new input (see
[above](#what-happens-when-you-send-again-with-the-same-sessionid)) and
returns the usual `AgentRun`. A paused run streams with
`agent.approvals.streamResolve()`.

`pending()` reads the shared checkpoint store, so it also lists runs that
are still running right now in another process or replica (as
`'in-progress'`, without `lastError`) - not only crashed ones. See below for
why resuming one of those is safe.

Each entry is `{ sessionId, kind, checkpointId, status, approvalId?,
approvalKind?, step, lastError?, attempts? }`:

Expand Down Expand Up @@ -17317,10 +17345,18 @@ console.log(object?.note); // typed: string | undefined

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.
2. Model calls that offer no tools carry `responseFormat: { type: 'json',
schema }` on `GenerateOptions`: every call of an agent without tools, the
"answer now" call when steps run out (see below), and the repair step. 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; OpenRouter
and the `pi` provider send a strict `json_schema` response format); a
custom provider may use it or ignore it. A call that offers tools goes
without it, and the model answers from the schema in the prompt: with a
strict response format next to tools, some models (`openai/gpt-4o-mini`
via OpenRouter) keep calling tools until `maxSteps` instead of answering.
`toolChoice: 'none'` counts as no tools offered. To send it on every
call anyway, pass `output: { schema, responseFormat: 'always' }`.
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. A near miss is read without a repair call: when the whole
Expand Down Expand Up @@ -17362,13 +17398,15 @@ call only and is not kept in `result.messages` or the session transcript.

## The schema in the prompt

Steps 1 and 2 send the schema twice: once inside the `## Output format`
system-prompt block, and once on `responseFormat`. When the provider enforces
`responseFormat` (OpenAI structured outputs, OpenRouter, the `pi` provider)
the prompt copy is redundant - on a large schema it costs around a thousand
tokens per call. Pass `output` as `{ schema, promptSchema: false }` to leave
the schema out of the prompt; the JSON-only ask stays and `responseFormat` is
sent unchanged:
Steps 1 and 2 send the schema twice on a call without tools: once inside the
`## Output format` system-prompt block, and once on `responseFormat`. When the
provider enforces `responseFormat` (OpenAI structured outputs, OpenRouter, the
`pi` provider) the prompt copy is redundant - on a large schema it costs
around a thousand tokens per call. Pass `output` as `{ schema, promptSchema:
false }` to leave the schema out of the prompt; the JSON-only ask stays, and
since `responseFormat` is then the only copy of the schema it goes on every
call, tool-offering ones included (`responseFormat` defaults to `'always'`
here; set `'final'` to keep it off those calls):

```ts
import { z } from 'zod';
Expand All @@ -17386,6 +17424,15 @@ provider - or model - treats `responseFormat` as a JSON-mode hint only (the
built-in `ai`-SDK providers drop the schema for models without
structured-outputs support; a custom provider may ignore it entirely). The
prompt copy is then the only place the model sees the shape to answer with.
Keep it too for an agent with tools: its tool-offering calls carry no
`responseFormat` by default, so the prompt copy is what they answer from.

| `output` | Schema in the prompt | `responseFormat` on calls offering tools | on calls without tools |
| --- | --- | --- | --- |
| `schema` or `{ schema }` | yes | no | yes |
| `{ schema, responseFormat: 'always' }` | yes | yes | yes |
| `{ schema, promptSchema: false }` | no | yes | yes |
| `{ schema, promptSchema: false, responseFormat: 'final' }` | no | no | yes |

## Repaired output and business rules

Expand Down Expand Up @@ -19677,6 +19724,7 @@ there is one. A change marked "no opt-out" closed a security or data-loss bug.
| An aborted run ends `finishReason: 'aborted'` at once, even when the provider ignores the signal. See [Cancellation](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/runs.md#cancellation). | no opt-out |
| The `openrouter` provider created without `apiKey` reads `OPENROUTER_API_KEY` (it sent `OPENAI_API_KEY`). | pass `apiKey` |
| The `@modelcontextprotocol/sdk` peer range is `^1.31.0`. | no opt-out |
| With `output`, `responseFormat` is sent only on model calls that offer no tools (an agent without tools, the "answer now" call at `maxSteps`, the repair step); a call that offers tools relies on the schema in the prompt. A strict `json_schema` next to tools made `openai/gpt-4o-mini` via OpenRouter call tools until `maxSteps`. See [How it works](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/structured-output.md#how-it-works). | `output: { schema, responseFormat: 'always' }` (already the default with `promptSchema: false`) |

### Tools, MCP and tracing

Expand Down
5 changes: 4 additions & 1 deletion src/createAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,7 +535,10 @@ export interface CreateAgentBase<TOutput extends StandardSchemaV1 = StandardSche
*
* As `{ schema, promptSchema: false }` the schema goes only on
* `responseFormat`, not into the system prompt too - for providers that
* enforce it, that saves sending the schema twice.
* enforce it, that saves sending the schema twice. `responseFormat` is
* sent only on calls that offer no tools unless
* `{ schema, responseFormat: 'always' }` (the default with
* `promptSchema: false`); see `OutputSpec`.
*
* @example
* ```ts
Expand Down
6 changes: 5 additions & 1 deletion src/execution/AgentExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1204,6 +1204,7 @@ export class AgentExecutor {
const prompt = outputRepairMessage(options.output, checked);
state.messages.push(prompt);
markRequestOnly(state, prompt);
state.outputRepair = true;
if (rejected?.role === 'assistant' && !rejected.toolCalls?.length && state.finalText && rejected.content === state.finalText) {
markRequestOnly(state, rejected);
}
Expand Down Expand Up @@ -1365,7 +1366,10 @@ export class AgentExecutor {
try {
// A hook's side calls (the compaction summarizer) count in the run's usage and budgets.
const sideGenerate = sideGenerator(options, agentSpanId, (measured) => recordStepUsage(state.usage, measured));
const generateRequest = await prepareGenerateRequest(options, state.messages, tools, callSignal, sideGenerate);
// The repair step's call carries the `output` schema as `responseFormat` even when it offers tools.
const answerOnly = state.outputRepair === true;
state.outputRepair = undefined;
const generateRequest = await prepareGenerateRequest(options, state.messages, tools, callSignal, sideGenerate, answerOnly);
callSignal?.throwIfAborted();
called = true;
const generated = await generateInSpan(options, generateRequest, state.messages, agentSpanId, callSignal, inputCheck, partial);
Expand Down
Loading
Loading