Skip to content
Open
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
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
49 changes: 49 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,49 @@
import { describe, expect, it } from 'vitest'
import { ChatClient } from '../src/chat-client'
import type { SubscribeConnectionAdapter } from '../src/connection-adapters'

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

function subscribeConnection(
chunk: ReturnType<typeof finished>,
): SubscribeConnectionAdapter {
return {
async *subscribe() {
yield {
type: '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