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
15 changes: 15 additions & 0 deletions .changeset/truncated-tool-result.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@tanstack/ai': minor
---

A tool call in a model answer that stopped at the output limit (finish reason `length`) never runs. The model can send it again when the loop strategy goes on.

- Behavior change: `chat()` never runs a tool call of an answer that stopped at the output limit. The input of such a call can be incomplete JSON. The call asks for no approval, and the client does not run a client tool for it. Before, such a call ran in some cases, for example as a client tool with `{}` input, or as a pending call of the next `chat()` request.
- Each cut call gets an error tool result. The default text tells the model to send the call again.
- The default `agentLoopStrategy` is now `combineStrategies([maxIterations(5), untilFinishReason(['length'])])`. By default, a cut answer ends the run with the finish reason `length`, as before.
- With a strategy that goes on after `length` (for example `maxIterations(5)`), the model is called again. The `RUN_FINISHED` event of the cut answer then has the finish reason `tool_calls`, so the client keeps the stream open.
- In a history message with `metadata.tanstack.finishReason: 'length'`, a call input that is not JSON goes to the provider as `{}`. Before, Bedrock threw on such a cut input.
- Middleware `onAfterToolCall` gets each cut call, with `ok: false` and an `Error` with the code `'max_tokens'`. Return `{ type: 'replaceResult', result }` to change the error text.
- Each assistant message that `chat()` stores on the server keeps the finish reason of its model call in `metadata.tanstack.finishReason`.

Migration: if you pass your own `agentLoopStrategy` and want a cut answer to end the run, add `untilFinishReason(['length'])` to it with `combineStrategies`.
1 change: 1 addition & 0 deletions docs/advanced/middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -737,6 +737,7 @@ Good to know:
- Each middleware gets the result of the one before it as `info.result`. The last replacement wins.
- A failed call stays failed. Your replacement becomes the error that the model reads.
- A `skip` result from `onBeforeToolCall` goes through this hook too. `info.result` is the parsed skip result, the same value the model gets.
- A call that the output limit cut goes through this hook too. It did not run, so `ok` is false, and `error` has the code `max_tokens`. See [When the output limit cuts a tool call](../chat/agentic-cycle#when-the-output-limit-cuts-a-tool-call).

### Tool hook order in one turn

Expand Down
2 changes: 1 addition & 1 deletion docs/api/ai.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ const stream = chat({
- `tools?` - Array of tools for function calling
- `context?` - Typed runtime context passed to server tools and middleware. If a tool or middleware declares a concrete context type, `chat()` requires a compatible value here
- `systemPrompts?` - System prompts to prepend to messages
- `agentLoopStrategy?` - Strategy for agent loops (default: `maxIterations(5)`). Strategies receive `{ iterationCount, finishReason, messages, toolCallCount, lastTurnToolCallCount }` and run between model turns. Iterations are model turns, not tool calls — for tool-call budgets use middleware (`onBeforeToolCall` + `onShouldContinue`); see [Tool-call budgets](../chat/agentic-cycle#tool-call-budgets-middleware-recipe).
- `agentLoopStrategy?` - Strategy for agent loops (default: `combineStrategies([maxIterations(5), untilFinishReason(["length"])])`). A strategy that goes on after a `length` answer lets the model send a [cut tool call](../chat/agentic-cycle#when-the-output-limit-cuts-a-tool-call) again. Strategies receive `{ iterationCount, finishReason, messages, toolCallCount, lastTurnToolCallCount }` and run between model turns. Iterations are model turns, not tool calls. For tool-call budgets, use middleware (`onBeforeToolCall` + `onShouldContinue`). See [Tool-call budgets](../chat/agentic-cycle#tool-call-budgets-middleware-recipe).
- `middleware?` - Array of chat middleware. Use `onShouldContinue` / `onBeforeToolCall` for app-owned tool budgets.
- `abortController?` - AbortController for cancellation
- `modelOptions?` - Provider-native model options. This is where sampling parameters live — `temperature`, `top_p`/`topP`, and the provider's token-limit key (`max_output_tokens`, `max_tokens`, `maxOutputTokens`, …) — under each provider's canonical name, rather than as generic root-level props. See [Moving Sampling Options into modelOptions](../migration/sampling-options-to-model-options). (Renamed from `providerOptions`.)
Expand Down
58 changes: 56 additions & 2 deletions docs/chat/agentic-cycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,11 +155,11 @@ export async function POST(request: Request) {
2. LLM calls `get_clothing_advice({temperature: 62, conditions: "cloudy"})` → Returns `{recommendation: "Light jacket recommended"}`
3. LLM generates: "The weather in San Francisco is 62°F and cloudy. I recommend wearing a light jacket."

The loop continues only while the model's finish reason is `tool_calls` (with pending tool calls) **and** the agent loop strategy permits another iteration; it ends as soon as the model returns a normal `stop` finish reason.
The loop continues only while the model's finish reason is `tool_calls` (with pending tool calls) **and** the agent loop strategy permits another iteration. It ends as soon as the model returns a normal `stop` finish reason. An answer that [hits the output limit](#when-the-output-limit-cuts-a-tool-call) is a special case.

### Controlling the loop

By default the loop is bounded by `maxIterations(5)` — after five **model turns** it stops even if the model would keep calling tools. Override this with the `agentLoopStrategy` option.
By default, the loop stops after five **model turns** (`maxIterations(5)`), even if the model calls more tools. It also stops after an answer that [hits the output limit](#when-the-output-limit-cuts-a-tool-call) (`untilFinishReason(["length"])`). Override this with the `agentLoopStrategy` option.

Other built-in strategies:

Expand Down Expand Up @@ -189,6 +189,60 @@ export async function POST(request: Request) {
}
```

### When the output limit cuts a tool call

A model can run out of output tokens in the middle of a tool call, for example while it writes a long file. The provider stops with the finish reason `length`, and the input of the call is not complete. `chat()` never runs such a call. It asks for no approval and starts no client tool. Each cut call gets an error tool result instead.

By default, the run then ends with the finish reason `length`. To let the model send the cut call again, use a strategy that does not stop on `length`:

```typescript
import { chat, maxIterations, toServerSentEventsResponse } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { writeFile } from "./tools";

export async function POST(request: Request) {
const { messages } = await request.json();
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages,
tools: [writeFile],
agentLoopStrategy: maxIterations(5),
});
return toServerSentEventsResponse(stream);
}
```

Then this happens after a cut answer:

1. The loop calls the model again. The model reads the error result and sends the call again.
2. `maxIterations` counts the cut answer as one model turn. A model that hits the limit every time stops at the iteration limit.

The client needs no change. When the loop goes on, the `RUN_FINISHED` event of the cut answer has the finish reason `tool_calls`, so `useChat` keeps the stream open for the next model turn.

The default error text tells the model to send the calls again and to split a large input. To use your own text, return a new result from the `onAfterToolCall` middleware hook. The error of a cut call has the code `max_tokens`:

```typescript
import { defineChatMiddleware } from "@tanstack/ai";

export const cutToolCallText = defineChatMiddleware({
onAfterToolCall(_ctx, info) {
const cut =
info.error instanceof Error &&
"code" in info.error &&
info.error.code === "max_tokens";
if (!cut) return;
return {
type: "replaceResult",
result: `Your call to ${info.toolName} was cut off. Write the file in parts of 200 lines or less.`,
};
},
});
```

Add it with `middleware: [cutToolCallText]` next to `agentLoopStrategy`.

On the server, each assistant message that `chat()` stores keeps `metadata.tanstack.finishReason`. A cut answer has `length`. If a transcript has a cut call with no result, `chat()` does not run that call.

### Tool-call budgets (middleware recipe)

> **Iterations ≠ tool calls.** One model turn can emit many parallel tool calls. `maxIterations` only bounds **model turns**. Strategies run *between* turns, so without a per-turn cap a single runaway turn can still fan out unbounded.
Expand Down
6 changes: 3 additions & 3 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@
"label": "Agentic Cycle",
"to": "chat/agentic-cycle",
"addedAt": "2026-04-15",
"updatedAt": "2026-07-21"
"updatedAt": "2026-10-13"
},
{
"label": "Thinking & Reasoning",
Expand Down Expand Up @@ -353,7 +353,7 @@
"label": "Middleware",
"to": "advanced/middleware",
"addedAt": "2026-04-15",
"updatedAt": "2026-10-12"
"updatedAt": "2026-10-13"
},
{
"label": "Built-in Middleware",
Expand Down Expand Up @@ -1479,7 +1479,7 @@
"label": "@tanstack/ai",
"to": "api/ai",
"addedAt": "2026-04-15",
"updatedAt": "2026-10-08"
"updatedAt": "2026-10-13"
},
{
"label": "@tanstack/ai-client",
Expand Down
Loading
Loading