280 lines
12 KiB
Plaintext
280 lines
12 KiB
Plaintext
---
|
|
title: "Human-in-the-loop"
|
|
sidebarTitle: "Human-in-the-loop"
|
|
description: "Pause the agent mid-response to ask the user a clarifying question, then resume with their answer."
|
|
---
|
|
|
|
Some turns need to stop and ask the user something before they can finish — picking between options, confirming a destructive action, or clarifying an ambiguous request. The AI SDK calls this **human-in-the-loop** (HITL), and the building block is a tool with no `execute` function.
|
|
|
|
When the LLM calls a tool that has no `execute`, `streamText` ends with the tool call still pending. The turn completes cleanly, the frontend renders UI to collect the answer, and when the user responds, a new turn resumes with the answer merged into the same assistant message.
|
|
|
|
## How it works
|
|
|
|
```
|
|
Turn N:
|
|
User message → run()
|
|
LLM streams text → calls askUser tool (no execute)
|
|
streamText ends with tool-call in `input-available` state
|
|
onTurnComplete fires (finishReason = "tool-calls")
|
|
Agent suspends (compute freed) — maxDuration does not tick while paused
|
|
|
|
Frontend:
|
|
Renders question + option buttons from tool input
|
|
User clicks → addToolOutput({ tool, toolCallId, output })
|
|
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls
|
|
→ sendMessage() fires next turn
|
|
|
|
Turn N+1:
|
|
hydrateMessages / accumulator sees the updated assistant message
|
|
run() is called, LLM continues from the tool result
|
|
onTurnComplete fires (finishReason = "stop", responseMessage is the FULL merged message)
|
|
```
|
|
|
|
The AI SDK's `toUIMessageStream` automatically reuses the assistant message ID across the pause (we pass `originalMessages` internally), so `responseMessage` in the post-resume `onTurnComplete` is the **full merged message** — the original text, the completed tool call, and any follow-up content — not just the new parts.
|
|
|
|
## Duration and cost while paused
|
|
|
|
A pause doesn't hold compute. After the model calls a no-execute tool, the turn finishes and the run stays warm for `idleTimeoutInSeconds` (default 30s), then **suspends** and frees its compute, the same way [`wait.for`](/wait-for) does. The user's `addToolOutput` wakes it back up.
|
|
|
|
Because the run is suspended while it waits, the human's thinking time is not billed and does **not** count against [`maxDuration`](/runs/max-duration). `maxDuration` measures active CPU time and excludes suspended waitpoint time, exactly like `wait.for`, so a user can take minutes, hours, or days to answer without the run hitting `maxDuration`. The only time that counts is each turn's actual compute plus the short warm window before each suspend.
|
|
|
|
You don't need to raise `maxDuration` or end the run to support long human waits. How long a single suspended pause stays open is governed by the run's suspend timeout, not `maxDuration`; if a wait outlives it the run ends, and the next `addToolOutput` boots a fresh continuation that picks up the resolved tool result.
|
|
|
|
## Backend: define the tool
|
|
|
|
A HITL tool has an `inputSchema` describing what the model can ask, but **no `execute` function**. When the LLM calls it, `streamText` returns control to your agent.
|
|
|
|
```ts trigger/my-chat.ts
|
|
import { chat } from "@trigger.dev/sdk/ai";
|
|
import { streamText, tool, stepCountIs } from "ai";
|
|
import { anthropic } from "@ai-sdk/anthropic";
|
|
import { z } from "zod";
|
|
|
|
const askUser = tool({
|
|
description:
|
|
"Ask the user a clarifying question when you need their input. " +
|
|
"Present 2-4 options for them to pick from.",
|
|
inputSchema: z.object({
|
|
question: z.string(),
|
|
options: z
|
|
.array(
|
|
z.object({
|
|
id: z.string(),
|
|
label: z.string(),
|
|
description: z.string().optional(),
|
|
})
|
|
)
|
|
.min(2)
|
|
.max(4),
|
|
}),
|
|
// No execute function — streamText ends, the frontend supplies the output
|
|
// via addToolOutput, and the next turn continues from the result.
|
|
});
|
|
|
|
export const myChat = chat.agent({
|
|
id: "my-chat",
|
|
tools: { askUser },
|
|
run: async ({ messages, tools, signal }) => {
|
|
return streamText({
|
|
model: anthropic("claude-sonnet-4-5"),
|
|
messages,
|
|
tools,
|
|
abortSignal: signal,
|
|
stopWhen: stepCountIs(15),
|
|
});
|
|
},
|
|
});
|
|
```
|
|
|
|
Declaring `tools` on the config (and reading them back from the payload) is the recommended shape for any agent with tools. See [Tools](/ai-chat/tools).
|
|
|
|
## Frontend: render the question and collect the answer
|
|
|
|
Two pieces on the client:
|
|
|
|
1. **UI for the pending tool call** — render when the tool part is in `input-available` state, i.e. the LLM has called the tool but there's no output yet.
|
|
2. **Auto-send on resolution** — use `sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls` so answering kicks off the next turn without the user having to hit "send."
|
|
|
|
```tsx
|
|
import { useChat, lastAssistantMessageIsCompleteWithToolCalls } from "@ai-sdk/react";
|
|
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
|
|
|
|
function ChatView({ chatId }: { chatId: string }) {
|
|
const transport = useTriggerChatTransport({
|
|
task: "my-chat",
|
|
accessToken: ({ chatId }) => mintChatAccessToken(chatId),
|
|
startSession: ({ chatId, clientData }) =>
|
|
startChatSession({ chatId, clientData }),
|
|
});
|
|
const { messages, sendMessage, addToolOutput } = useChat({
|
|
id: chatId,
|
|
transport,
|
|
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
|
|
});
|
|
|
|
return (
|
|
<>
|
|
{messages.map((m) =>
|
|
m.parts.map((part, i) => {
|
|
if (part.type === "tool-askUser" && part.state === "input-available") {
|
|
return (
|
|
<AskUserCard
|
|
key={i}
|
|
question={part.input.question}
|
|
options={part.input.options}
|
|
onAnswer={(opt) =>
|
|
addToolOutput({
|
|
tool: "askUser",
|
|
toolCallId: part.toolCallId,
|
|
output: { optionId: opt.id, label: opt.label },
|
|
})
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
if (part.type === "text") return <Markdown key={i}>{part.text}</Markdown>;
|
|
return null;
|
|
})
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
```
|
|
|
|
`addToolOutput` patches the assistant message locally with `state: "output-available"` and fills in `output`. `lastAssistantMessageIsCompleteWithToolCalls` detects that every pending tool call now has a result, and `useChat` fires a new `sendMessage` — the backend picks it up as the next turn.
|
|
|
|
## Detecting a paused turn in `onTurnComplete`
|
|
|
|
Two ways to detect "this turn paused for user input" vs "this turn finished normally":
|
|
|
|
### Via `finishReason` (recommended)
|
|
|
|
The AI SDK's finish reason is surfaced on every `onTurnComplete` event. If the model stopped on tool calls, it's `"tool-calls"`:
|
|
|
|
```ts
|
|
onTurnComplete: async ({ finishReason, responseMessage }) => {
|
|
if (finishReason === "tool-calls") {
|
|
// Turn paused — assistant message has pending tool call(s)
|
|
const pending = responseMessage?.parts.filter(
|
|
(p) => p.type.startsWith("tool-") && p.state === "input-available"
|
|
);
|
|
// Persist as a checkpoint / partial turn
|
|
} else {
|
|
// finishReason === "stop" — normal completion
|
|
// Persist as a completed turn
|
|
}
|
|
};
|
|
```
|
|
|
|
<Note>
|
|
`finishReason` is only undefined for manual `chat.pipe()` flows or aborted streams. For the common `run() → return streamText(...)` pattern it's always populated.
|
|
</Note>
|
|
|
|
### Via response parts
|
|
|
|
If you need more nuance (e.g. which specific tool is pending), use `chat.history.getPendingToolCalls()`:
|
|
|
|
```ts
|
|
const pending = chat.history.getPendingToolCalls();
|
|
// [{ toolCallId, toolName, messageId }]
|
|
```
|
|
|
|
The result reflects the most recent assistant message: the one waiting on `addToolOutput`. Use it from `onAction` to gate fresh user turns ("can't send a new message while a HITL is open"), or from `onTurnComplete` to decide what to persist.
|
|
|
|
Both `finishReason === "tool-calls"` and `chat.history.getPendingToolCalls().length > 0` are equivalent in practice. Use `finishReason` for dispatch, the helper for detail.
|
|
|
|
### Acting once per net-new tool result
|
|
|
|
When the user's `addToolOutput` round-trips a tool answer back to the agent, the wire message carries the resolved tool part. If you want to fire side-effects (audit log, billing, notifications) exactly once per resolved tool call, do it in `hydrateMessages` before the runtime merges. `chat.history.extractNewToolResults(message)` returns only the parts whose `toolCallId` isn't already resolved on the chain:
|
|
|
|
```ts
|
|
hydrateMessages: async ({ incomingMessages }) => {
|
|
for (const msg of incomingMessages) {
|
|
if (msg.role !== "assistant") continue;
|
|
for (const r of chat.history.extractNewToolResults(msg)) {
|
|
await auditLog.record({
|
|
toolCallId: r.toolCallId,
|
|
toolName: r.toolName,
|
|
output: r.output,
|
|
errorText: r.errorText, // set only for output-error parts
|
|
});
|
|
}
|
|
}
|
|
return incomingMessages;
|
|
},
|
|
```
|
|
|
|
`extractNewToolResults` compares against the current `chat.history`. By the time `onTurnComplete` fires, the chain already contains `responseMessage`, so the helper returns `[]` there. Use it where the message is from outside the accumulator: `hydrateMessages`, `onAction` if the action carries a message, or any custom pre-merge code path.
|
|
|
|
## Persistence: one message vs one record per pause
|
|
|
|
Because the AI SDK reuses the assistant message ID across the pause, the "same turn" from the user's perspective maps to **two `onTurnComplete` firings** on the server — but both receive a `responseMessage` with the **same `id`**, and the second firing's `responseMessage` contains the fully merged content.
|
|
|
|
Two common persistence patterns:
|
|
|
|
### Overwrite on every turn (simplest)
|
|
|
|
Just store the latest `uiMessages` array on every `onTurnComplete`. The paused-turn write is overwritten by the resume-turn write; the final DB state has the full merged message.
|
|
|
|
```ts
|
|
onTurnComplete: async ({ chatId, uiMessages }) => {
|
|
await db.chat.update({
|
|
where: { id: chatId },
|
|
data: { messages: uiMessages },
|
|
});
|
|
},
|
|
```
|
|
|
|
Use this unless you specifically need an audit trail.
|
|
|
|
### Checkpoint nodes (immutable history)
|
|
|
|
For apps that want every pause point recorded as its own immutable snapshot (branching, replay, diff review), save a checkpoint when paused and a sibling when complete:
|
|
|
|
```ts
|
|
onTurnComplete: async ({ chatId, responseMessage, finishReason, uiMessages }) => {
|
|
if (!responseMessage) return;
|
|
|
|
if (finishReason === "tool-calls") {
|
|
// Paused — save a checkpoint
|
|
await db.turnCheckpoint.create({
|
|
data: {
|
|
chatId,
|
|
messageId: responseMessage.id,
|
|
parts: responseMessage.parts,
|
|
kind: "partial",
|
|
},
|
|
});
|
|
} else {
|
|
// Completed — save a sibling with the merged full message
|
|
await db.turnCheckpoint.create({
|
|
data: {
|
|
chatId,
|
|
messageId: responseMessage.id,
|
|
parts: responseMessage.parts,
|
|
kind: "final",
|
|
},
|
|
});
|
|
}
|
|
|
|
// Always update the canonical chat record for `hydrateMessages` to load
|
|
await db.chat.update({
|
|
where: { id: chatId },
|
|
data: { messages: uiMessages },
|
|
});
|
|
};
|
|
```
|
|
|
|
Both writes see `responseMessage.id` as the same value — they're checkpoints of the same logical message. Grouping by `messageId` + ordering by `createdAt` gives you the progression.
|
|
|
|
## Multi-pause turns
|
|
|
|
A single logical turn can pause more than once — the LLM asks question A, gets the answer, thinks, then asks question B before finishing. Each pause fires its own `onTurnComplete` with `finishReason === "tool-calls"`; only the last firing has `finishReason === "stop"`. The checkpoint pattern above handles this naturally — each pause adds a new checkpoint sharing the same `responseMessage.id`.
|
|
|
|
## Gotchas
|
|
|
|
- **Don't set an `execute` function on the HITL tool.** If it has one, `streamText` will call it immediately instead of handing control back.
|
|
- **The frontend must use `sendAutomaticallyWhen`.** Without it, the user has to press Enter after answering — `addToolOutput` updates local state but doesn't fire a new turn by itself.
|
|
- **Don't mutate `responseMessage` in `onTurnComplete`.** It's the captured snapshot. To add custom parts, use `chat.response.append()` in `onBeforeTurnComplete` (while the stream is open).
|
|
- **Stop handling.** If the user stops the run while a pause is active (`chat.stop()` on the transport), `onTurnComplete` fires with `stopped: true` and `finishReason` reflecting the last successful step. Treat stopped paused turns the same as stopped normal turns.
|