Skip to content
Merged
8 changes: 8 additions & 0 deletions .changeset/loop-stopped-signal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@tanstack/ai': minor
'@tanstack/ai-harness': minor
---

A run that the agent loop stops before the model finished now says so. When `agentLoopStrategy` (for example `maxIterations`) or a middleware `onShouldContinue` returns `false` while tool results wait for the next model call, `chat()` ends the stream with a `RUN_FINISHED` that has `metadata.tanstack.loopStopped: true`, and the last stored assistant message gets the same field. `finishReason` stays the reason of the last model call (`tool_calls`, or `length` for a cut answer). A run that the model ends has no `loopStopped`.

In `@tanstack/ai-harness`, a turn that the loop stops settles with `{ outcome: 'completed', loopStopped: true }`. `session.settled()`, the `harness.input.settled` event of `createHarnessClient().events()`, and the transcript all carry it.
2 changes: 2 additions & 0 deletions docs/api/ai.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,8 @@ An iteration is one model turn, not one tool call. One turn can call many tools

A strategy that goes on after a `length` answer makes the model send the [cut tool call](../chat/agentic-cycle#when-the-output-limit-cuts-a-tool-call) again.

When a strategy stops the loop while tool results wait for the model, the last `RUN_FINISHED` has `metadata.tanstack.loopStopped: true`. See [Know when the loop stopped the run](../chat/agentic-cycle#know-when-the-loop-stopped-the-run).

## Response helpers

Each helper turns a `chat()` stream into a response for one transport. Pick the one that matches the client connection adapter. See [Transports](../transports/overview).
Expand Down
62 changes: 61 additions & 1 deletion docs/chat/agentic-cycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,66 @@ A strategy is a function. It gets the loop state and returns `true` to allow one
- `toolCallCount`: the tool calls so far in this run.
- `lastTurnToolCallCount`: the tool calls in the last batch.

### Know when the loop stopped the run

Read `metadata.tanstack.loopStopped` on the last `RUN_FINISHED` event. It is `true` when the strategy, or a middleware `onShouldContinue`, stopped the loop while tool results waited for the model. In that case, the model did not finish its task.

A run that the model ends has no `loopStopped`. The `finishReason` is always the reason of the last model call, often `tool_calls` for a stopped run.

On the server:

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

const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages: [{ role: "user", content: "Plan my week of outfits." }],
threadId: "thread-1",
tools: [getWeather],
agentLoopStrategy: maxIterations(3),
});

for await (const chunk of stream) {
if (chunk.type === "RUN_FINISHED" && chunk.metadata?.tanstack?.loopStopped) {
console.log("The loop stopped before the model finished.");
}
}
```

On the client, read the same event in `onChunk`:

```tsx
import { useState } from "react";
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";

export function Chat() {
const [loopStopped, setLoopStopped] = useState(false);
const { sendMessage } = useChat({
connection: fetchServerSentEvents("/api/chat"),
threadId: "support-chat",
onChunk: (chunk) => {
if (chunk.type === "RUN_FINISHED" && chunk.metadata?.tanstack?.loopStopped) {
setLoopStopped(true);
}
},
});

return (
<>
<button type="button" onClick={() => sendMessage("Plan my week of outfits.")}>
Ask
</button>
{loopStopped ? <p>The agent stopped at its step limit.</p> : null}
</>
);
}
```

The last assistant message that `chat()` stores on the server also gets `metadata.tanstack.loopStopped: true`. So a saved transcript keeps the signal.

## Advanced

### When the output limit cuts a tool call
Expand All @@ -225,7 +285,7 @@ A model can run out of output tokens in the middle of a tool call, for example w
- It starts no client tool.
- Each cut call gets an error tool result.

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`:
By default, the run then ends with the finish reason `length` and [`loopStopped`](#know-when-the-loop-stopped-the-run). To let the model send the cut call again, use a strategy that does not stop on `length`:

```typescript
// api/chat.ts
Expand Down
6 changes: 3 additions & 3 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@
"label": "Agentic Cycle",
"to": "chat/agentic-cycle",
"addedAt": "2026-04-15",
"updatedAt": "2026-10-22"
"updatedAt": "2026-11-05"
},
{
"label": "Thinking & Reasoning",
Expand Down Expand Up @@ -1133,7 +1133,7 @@
"label": "Send inputs safely",
"to": "harness/inputs",
"addedAt": "2026-10-13",
"updatedAt": "2026-10-22"
"updatedAt": "2026-11-05"
},
{
"label": "Run a tool without a model",
Expand Down Expand Up @@ -2038,7 +2038,7 @@
"label": "@tanstack/ai",
"to": "api/ai",
"addedAt": "2026-04-15",
"updatedAt": "2026-11-02"
"updatedAt": "2026-11-05"
},
{
"label": "@tanstack/ai-client",
Expand Down
5 changes: 4 additions & 1 deletion docs/harness/inputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,16 @@ const settlement = await session.settled({
if (settlement.outcome === 'failed') {
console.log(settlement.error?.code) // 'attempts_exhausted', 'timeout', or undefined
}
if (settlement.outcome === 'completed' && settlement.loopStopped) {
console.log('The agent loop stopped the turn before the model finished.')
}
```

`signal` is optional. When it aborts first, `settled` rejects with its reason. The input keeps running.

On a host that does not own the thread, the input waits for the owner. If the owner refuses it, `settled` rejects with `InputRejectedError`. The owner refuses an input when the thread is busy and the input has `busy: 'reject'`, or when the log has its id with another message. The log then has a `harness.offer.rejected` record.

- `completed`: the turn has its answer.
- `completed`: the turn has its answer. With `loopStopped: true`, the agent loop stopped the turn while tool results waited for the model, so the model did not finish. The default `agentLoopStrategy` of a harness is `maxIterations(50)`. See [Know when the loop stopped the run](../chat/agentic-cycle#know-when-the-loop-stopped-the-run).
- `failed`: the turn failed. `error.code` is set for a limit.
- `aborted`: someone cancelled the turn. The text that the model streamed before the cancel stays in the transcript as its answer.
- `interrupted`: the turn waits for a person. The `resolve` that answers it is a new input. See [Wait for a person](./waits).
Expand Down
2 changes: 2 additions & 0 deletions packages/ai-client/src/chat-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,8 @@ function connectionDrainsOnSend(connection: ConnectionAdapter): boolean {
function isIntermediateToolTurn(chunk: StreamChunk): boolean {
if (chunk.type !== 'RUN_FINISHED') return false
if (chunk.outcome?.type === 'interrupt') return false
// A loop stop keeps finishReason tool_calls. That event ends the stream.
if (tanstackMetadata(chunk)?.loopStopped === true) return false
const extra = chunk as StreamChunk & { finishReason?: unknown }
if (extra.finishReason !== undefined) {
return extra.finishReason === 'tool_calls'
Expand Down
48 changes: 48 additions & 0 deletions packages/ai-client/tests/chat-client-loop-stopped.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import { EventType, type StreamChunk } from '@tanstack/ai/client'
import { ChatClient } from '../src/chat-client'
import type { SubscribeConnectionAdapter } from '../src/connection-adapters'

function finished(loopStopped: boolean): StreamChunk {
return {
type: EventType.RUN_FINISHED,
runId: 'run-1',
threadId: 'thread-1',
timestamp: 1,
metadata: {
tanstack: {
finishReason: 'tool_calls',
...(loopStopped ? { loopStopped: true as const } : {}),
},
},
}
}

function subscribeConnection(chunk: StreamChunk): SubscribeConnectionAdapter {
return {
async *subscribe() {
yield {
type: EventType.RUN_STARTED,
runId: 'run-1',
threadId: 'thread-1',
timestamp: 0,
}
yield chunk
},
send: async () => {},
}
}

describe('loopStopped on a subscribe connection', () => {
it('resolves sendMessage when the loop stops on tool_calls', async () => {
const client = new ChatClient({
threadId: 'thread-1',
connection: subscribeConnection(finished(true)),
})

await client.sendMessage('hi')

expect(client.getIsLoading()).toBe(false)
expect(client.getSnapshot().status).toBe('ready')
})
})
7 changes: 6 additions & 1 deletion packages/ai-harness/src/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -944,7 +944,12 @@ export function foldEntry(
...(record.operationId ? { operationId: record.operationId } : {}),
...(record.outcome === 'failed'
? { outcome: 'failed', error: record.error }
: { outcome: record.outcome }),
: {
outcome: record.outcome,
...(record.outcome === 'completed' && record.loopStopped
? { loopStopped: true as const }
: {}),
}),
},
}))
state.thread = settleRunNode(state.thread, record)
Expand Down
13 changes: 12 additions & 1 deletion packages/ai-harness/src/session/turn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -678,6 +678,8 @@ export abstract class SessionTurn<
let openMessageId: string | undefined
let interrupts: Array<Interrupt> | undefined
let failure: string | undefined
/** The last RUN_FINISHED says the agent loop stopped the model. */
let loopStopped = false
/** With `outputSchema`: the value of the last call, or why its check failed. */
let structured: unknown
let structuredError: { code: string; rawText?: string } | undefined
Expand Down Expand Up @@ -1364,6 +1366,12 @@ export abstract class SessionTurn<
) {
isRunOpen = false
}
if (
chunk.type === EventType.RUN_FINISHED &&
chunk.metadata?.tanstack?.loopStopped === true
) {
loopStopped = true
}
if (
chunk.type === EventType.TEXT_MESSAGE_CONTENT ||
chunk.type === EventType.REASONING_MESSAGE_CONTENT ||
Expand Down Expand Up @@ -1853,7 +1861,10 @@ export abstract class SessionTurn<
await settleTurn({ outcome: 'interrupted' })
operation.finish('interrupted', { text, interrupts: [], media: captured })
} else {
await settleTurn({ outcome: 'completed' })
await settleTurn({
outcome: 'completed',
...(loopStopped ? { loopStopped: true } : {}),
})
operation.finish('completed', {
text,
media: captured,
Expand Down
12 changes: 11 additions & 1 deletion packages/ai-harness/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -481,9 +481,19 @@ export type InputSettlement = {
* `interrupted`: the turn stopped for human input. The `resolve` that
* answers it is a new input with its own settlement.
*/
outcome: 'completed' | 'aborted' | 'interrupted'
outcome: 'aborted' | 'interrupted'
error?: never
}
| {
outcome: 'completed'
error?: never
/**
* The agent loop stopped before the model was done:
* `agentLoopStrategy` (default `maxIterations(50)`) or a middleware
* `onShouldContinue` said stop while tool results waited.
*/
loopStopped?: true
}
| {
outcome: 'failed'
/**
Expand Down
117 changes: 117 additions & 0 deletions packages/ai-harness/tests/loop-stopped.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { describe, expect, it } from 'vitest'
import { z } from 'zod'
import { EventType, maxIterations, toolDefinition } from '@tanstack/ai'
import { memoryPersistence } from '@tanstack/ai-persistence'
import { HARNESS_EVENTS, createHarnessHost, defineHarness } from '../src'
import { createHarnessHandler } from '../src/server'
import { createHarnessClient } from '../src/client'
import { mockAdapter, text, toolCall } from './helpers'
import type { ModelMessage } from '@tanstack/ai'
import type { InputSettlement } from '../src'
import type { Reply } from './helpers'

const lookup = toolDefinition({
name: 'lookup',
description: 'Look something up',
inputSchema: z.object({ q: z.string() }),
}).server(async () => ({ found: true }))

/** A harness with `maxIterations(2)`, its host session, and a client. */
async function open(replies: Array<Reply>) {
const { adapter, calls } = mockAdapter(replies)
const harness = defineHarness({
name: 'test/loop-stopped',
adapter,
tools: [lookup],
agentLoopStrategy: maxIterations(2),
})
const host = createHarnessHost({
persistence: memoryPersistence(),
access: {
getUser: () => ({ id: 'u' }),
rules: { isAllowed: () => true },
},
})
const handler = createHarnessHandler({ host, harness })
const client = createHarnessClient<typeof harness>({
url: 'http://local/api/harness/',
threadId: 't1',
fetch: (input, init) => handler(new Request(input, init)),
})
const session = await host.open(harness, {
threadId: 't1',
user: { id: 'u' },
})
return { host, session, client, calls }
}

/** The first `harness.input.settled` value that the client reads. */
async function clientSettlement(
client: Awaited<ReturnType<typeof open>>['client'],
): Promise<InputSettlement | undefined> {
for await (const { event } of client.events({
from: '0',
signal: AbortSignal.timeout(2_000),
})) {
if (
event.type === EventType.CUSTOM &&
event.name === HARNESS_EVENTS.inputSettled
) {
return event.value
}
}
return undefined
}

const loopStoppedOf = (message: ModelMessage | undefined) => {
const metadata = message?.metadata
if (typeof metadata !== 'object' || metadata === null) return undefined
const tanstack: unknown = 'tanstack' in metadata ? metadata.tanstack : null
if (typeof tanstack !== 'object' || tanstack === null) return undefined
return 'loopStopped' in tanstack ? tanstack.loopStopped : undefined
}

describe('a turn that the agent loop stops', () => {
it('settles completed with loopStopped, for the session and the client', async () => {
const { host, session, client, calls } = await open([
() => toolCall('lookup', { q: 'a' }, 'call-1'),
() => toolCall('lookup', { q: 'b' }, 'call-2'),
() => text('never'),
])

await session.send('look it up', { inputId: 'in-1' })

expect(calls).toHaveLength(2)
expect(await session.settled({ inputId: 'in-1' })).toMatchObject({
outcome: 'completed',
loopStopped: true,
})
expect(await clientSettlement(client)).toMatchObject({
inputId: 'in-1',
outcome: 'completed',
loopStopped: true,
})
const assistant = (await client.transcript()).findLast(
(message) => message.role === 'assistant',
)
expect(loopStoppedOf(assistant)).toBe(true)
await host.close()
})

it('has no loopStopped when the model ends the turn', async () => {
const { host, session, client } = await open([
() => toolCall('lookup', { q: 'a' }, 'call-1'),
() => text('Found it.'),
])

await session.send('look it up', { inputId: 'in-1' })

const settlement = await session.settled({ inputId: 'in-1' })
expect(settlement).toMatchObject({ outcome: 'completed' })
expect(settlement).not.toHaveProperty('loopStopped')
expect(await clientSettlement(client)).not.toHaveProperty('loopStopped')
const transcript = await client.transcript()
expect(transcript.some((message) => loopStoppedOf(message))).toBe(false)
await host.close()
})
})
Loading
Loading