@trigger.dev/sdk 4.6.4 → 4.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commonjs/v3/ai.d.ts +5 -2
- package/dist/commonjs/v3/ai.js +189 -266
- package/dist/commonjs/v3/ai.js.map +1 -1
- package/dist/commonjs/v3/chat-client.js +7 -0
- package/dist/commonjs/v3/chat-client.js.map +1 -1
- package/dist/commonjs/v3/chat-server.d.ts +1 -0
- package/dist/commonjs/v3/chat-server.js +8 -0
- package/dist/commonjs/v3/chat-server.js.map +1 -1
- package/dist/commonjs/v3/chat.d.ts +28 -4
- package/dist/commonjs/v3/chat.js +41 -9
- package/dist/commonjs/v3/chat.js.map +1 -1
- package/dist/commonjs/v3/chatRouteWait.d.ts +21 -0
- package/dist/commonjs/v3/chatRouteWait.js +43 -0
- package/dist/commonjs/v3/chatRouteWait.js.map +1 -0
- package/dist/commonjs/v3/compactionResponse.js +5 -0
- package/dist/commonjs/v3/compactionResponse.js.map +1 -1
- package/dist/commonjs/v3/concurrency-shared.d.ts +13 -0
- package/dist/commonjs/v3/concurrency-shared.js +35 -0
- package/dist/commonjs/v3/concurrency-shared.js.map +1 -0
- package/dist/commonjs/v3/concurrencyLimits.d.ts +73 -0
- package/dist/commonjs/v3/concurrencyLimits.js +166 -0
- package/dist/commonjs/v3/concurrencyLimits.js.map +1 -0
- package/dist/commonjs/v3/index.d.ts +2 -1
- package/dist/commonjs/v3/index.js +3 -1
- package/dist/commonjs/v3/index.js.map +1 -1
- package/dist/commonjs/v3/managedChatResponse.d.ts +44 -0
- package/dist/commonjs/v3/managedChatResponse.js +233 -0
- package/dist/commonjs/v3/managedChatResponse.js.map +1 -0
- package/dist/commonjs/v3/queues.d.ts +31 -0
- package/dist/commonjs/v3/queues.js +31 -0
- package/dist/commonjs/v3/queues.js.map +1 -1
- package/dist/commonjs/v3/shared.d.ts +18 -1
- package/dist/commonjs/v3/shared.js +137 -47
- package/dist/commonjs/v3/shared.js.map +1 -1
- package/dist/commonjs/v3/steeringContext.d.ts +41 -0
- package/dist/commonjs/v3/steeringContext.js +118 -0
- package/dist/commonjs/v3/steeringContext.js.map +1 -0
- package/dist/commonjs/v3/transcriptStorage.d.ts +4 -1
- package/dist/commonjs/v3/transcriptStorage.js +51 -4
- package/dist/commonjs/v3/transcriptStorage.js.map +1 -1
- package/dist/commonjs/version.js +1 -1
- package/dist/esm/v3/ai.d.ts +5 -2
- package/dist/esm/v3/ai.js +189 -266
- package/dist/esm/v3/ai.js.map +1 -1
- package/dist/esm/v3/chat-client.js +7 -0
- package/dist/esm/v3/chat-client.js.map +1 -1
- package/dist/esm/v3/chat-server.d.ts +1 -0
- package/dist/esm/v3/chat-server.js +8 -0
- package/dist/esm/v3/chat-server.js.map +1 -1
- package/dist/esm/v3/chat.d.ts +28 -4
- package/dist/esm/v3/chat.js +41 -9
- package/dist/esm/v3/chat.js.map +1 -1
- package/dist/esm/v3/chatRouteWait.d.ts +21 -0
- package/dist/esm/v3/chatRouteWait.js +40 -0
- package/dist/esm/v3/chatRouteWait.js.map +1 -0
- package/dist/esm/v3/compactionResponse.js +5 -0
- package/dist/esm/v3/compactionResponse.js.map +1 -1
- package/dist/esm/v3/concurrency-shared.d.ts +13 -0
- package/dist/esm/v3/concurrency-shared.js +31 -0
- package/dist/esm/v3/concurrency-shared.js.map +1 -0
- package/dist/esm/v3/concurrencyLimits.d.ts +73 -0
- package/dist/esm/v3/concurrencyLimits.js +158 -0
- package/dist/esm/v3/concurrencyLimits.js.map +1 -0
- package/dist/esm/v3/index.d.ts +2 -1
- package/dist/esm/v3/index.js +2 -1
- package/dist/esm/v3/index.js.map +1 -1
- package/dist/esm/v3/managedChatResponse.d.ts +44 -0
- package/dist/esm/v3/managedChatResponse.js +228 -0
- package/dist/esm/v3/managedChatResponse.js.map +1 -0
- package/dist/esm/v3/queues.d.ts +31 -0
- package/dist/esm/v3/queues.js +31 -0
- package/dist/esm/v3/queues.js.map +1 -1
- package/dist/esm/v3/shared.d.ts +18 -1
- package/dist/esm/v3/shared.js +136 -47
- package/dist/esm/v3/shared.js.map +1 -1
- package/dist/esm/v3/steeringContext.d.ts +41 -0
- package/dist/esm/v3/steeringContext.js +113 -0
- package/dist/esm/v3/steeringContext.js.map +1 -0
- package/dist/esm/v3/transcriptStorage.d.ts +4 -1
- package/dist/esm/v3/transcriptStorage.js +51 -4
- package/dist/esm/v3/transcriptStorage.js.map +1 -1
- package/dist/esm/version.js +1 -1
- package/docs/ai-chat/client-protocol.mdx +3 -1
- package/docs/ai-chat/error-handling.mdx +44 -76
- package/docs/ai-chat/fast-starts.mdx +1 -1
- package/docs/ai-chat/frontend.mdx +27 -21
- package/docs/ai-chat/patterns/branching-conversations.mdx +95 -230
- package/docs/ai-chat/patterns/human-in-the-loop.mdx +166 -164
- package/docs/ai-chat/patterns/tool-result-auditing.mdx +28 -27
- package/docs/ai-chat/patterns/version-upgrades.mdx +4 -4
- package/docs/ai-chat/pending-messages.mdx +19 -5
- package/docs/ai-chat/quick-start.mdx +26 -20
- package/docs/ai-chat/reference.mdx +21 -3
- package/docs/ai-chat/sessions.mdx +1 -1
- package/docs/ai-chat/testing.mdx +16 -4
- package/docs/concurrency.mdx +384 -0
- package/docs/config/config-file.mdx +2 -0
- package/docs/database-connections.mdx +3 -3
- package/docs/deploy-environment-variables.mdx +6 -0
- package/docs/deployment/atomic-deployment.mdx +416 -132
- package/docs/deployment/overview.mdx +2 -2
- package/docs/deployment/preview-branches.mdx +2 -2
- package/docs/github-actions.mdx +26 -16
- package/docs/github-integration.mdx +2 -2
- package/docs/idempotency.mdx +43 -5
- package/docs/introduction.mdx +1 -1
- package/docs/limits.mdx +16 -6
- package/docs/manual-setup.mdx +1 -1
- package/docs/observability/query.mdx +25 -0
- package/docs/queues.mdx +271 -0
- package/docs/reports.mdx +1 -1
- package/docs/runs/priority.mdx +2 -25
- package/docs/self-hosting/env/webapp.mdx +9 -0
- package/docs/self-hosting/kubernetes.mdx +14 -3
- package/docs/tasks/overview.mdx +3 -5
- package/docs/troubleshooting-alerts.mdx +124 -1
- package/docs/troubleshooting.mdx +12 -0
- package/docs/vercel-integration.mdx +6 -7
- package/docs/versioning.mdx +1 -1
- package/docs/writing-tasks-introduction.mdx +2 -1
- package/package.json +2 -2
- package/docs/deployment/version-skew-protection.mdx +0 -492
- package/docs/queue-concurrency.mdx +0 -358
|
@@ -1,52 +1,49 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: "Human-in-the-loop"
|
|
3
3
|
sidebarTitle: "Human-in-the-loop"
|
|
4
|
-
description: "
|
|
4
|
+
description: "Ask users to choose an option or approve a tool call, then continue the agent with their response."
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
**A chat agent can wait for a user to supply a tool result or approve a tool before it runs.** Choose the pattern based on what the user needs to provide:
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
| User interaction | Tool definition | Frontend response |
|
|
10
|
+
| --- | --- | --- |
|
|
11
|
+
| Answer a question or choose a candidate | Omit `execute` | `addToolOutput` supplies the result |
|
|
12
|
+
| Approve or deny an action with known inputs | Set `needsApproval: true` and keep `execute` | `addToolApprovalResponse` authorizes or denies execution |
|
|
13
|
+
|
|
14
|
+
The examples below use AI SDK 6 or later and build on the [Quick start](/ai-chat/quick-start), including its session and token server actions. For approve/deny buttons, see [Approve a tool before execution](#approve-a-tool-before-execution).
|
|
10
15
|
|
|
11
16
|
## How it works
|
|
12
17
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
→ sendMessage() fires next turn
|
|
26
|
-
|
|
27
|
-
Turn N+1:
|
|
28
|
-
hydrateMessages / accumulator sees the updated assistant message
|
|
29
|
-
run() is called, LLM continues from the tool result
|
|
30
|
-
onTurnComplete fires (finishReason = "stop", responseMessage is the FULL merged message)
|
|
18
|
+
When the model calls a tool without `execute`, `streamText` finishes with a pending tool call. Your frontend renders the question. Calling `addToolOutput` supplies the answer, and `sendAutomaticallyWhen` starts the next turn.
|
|
19
|
+
|
|
20
|
+
```mermaid
|
|
21
|
+
sequenceDiagram
|
|
22
|
+
participant Agent
|
|
23
|
+
participant UI as Your frontend
|
|
24
|
+
participant User
|
|
25
|
+
Agent->>UI: askUser tool call (input-available)
|
|
26
|
+
UI->>User: Show question and options
|
|
27
|
+
User->>UI: Choose an option
|
|
28
|
+
UI->>Agent: addToolOutput + automatic send
|
|
29
|
+
Agent->>UI: Continue with the answer
|
|
31
30
|
```
|
|
32
31
|
|
|
33
|
-
The
|
|
32
|
+
The assistant message keeps its ID across the pause. After resuming, `onTurnComplete` receives the full merged `responseMessage`, including the question, tool result, and follow-up text.
|
|
34
33
|
|
|
35
34
|
## Duration and cost while paused
|
|
36
35
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
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.
|
|
36
|
+
After the turn finishes, the run stays active for `idleTimeoutInSeconds` (30 seconds by default), then suspends and releases compute. Set it to `0` to suspend immediately. The active idle window counts toward compute usage and [`maxDuration`](/runs/max-duration); suspended time doesn't.
|
|
40
37
|
|
|
41
|
-
|
|
38
|
+
[`turnTimeout`](/ai-chat/reference#chatagent) controls how long a run waits for the next message (default `"1h"`). When it expires, the run ends. A later answer starts a continuation run that restores the conversation from [transcript storage](/ai-chat/transcript-storage). Raising `maxDuration` isn't necessary for time spent suspended.
|
|
42
39
|
|
|
43
40
|
## Backend: define the tool
|
|
44
41
|
|
|
45
|
-
|
|
42
|
+
Define an `inputSchema` for the question and omit `execute` so the frontend can supply the answer. When the model calls `askUser`, `streamText` returns control to your agent.
|
|
46
43
|
|
|
47
44
|
```ts trigger/my-chat.ts
|
|
48
|
-
import { chat } from "@trigger.dev/sdk/ai";
|
|
49
|
-
import {
|
|
45
|
+
import { chat, type InferChatUIMessageFromTools } from "@trigger.dev/sdk/ai";
|
|
46
|
+
import { tool, stepCountIs } from "ai";
|
|
50
47
|
import { anthropic } from "@ai-sdk/anthropic";
|
|
51
48
|
import { z } from "zod";
|
|
52
49
|
|
|
@@ -67,14 +64,17 @@ const askUser = tool({
|
|
|
67
64
|
.min(2)
|
|
68
65
|
.max(4),
|
|
69
66
|
}),
|
|
70
|
-
|
|
71
|
-
//
|
|
67
|
+
outputSchema: z.object({ optionId: z.string(), label: z.string() }),
|
|
68
|
+
// The frontend supplies the result with addToolOutput.
|
|
72
69
|
});
|
|
73
70
|
|
|
74
|
-
|
|
71
|
+
const tools = { askUser };
|
|
72
|
+
export type ChatMessage = InferChatUIMessageFromTools<typeof tools>;
|
|
73
|
+
|
|
74
|
+
export const myChat = chat.withUIMessage<ChatMessage>().agent({
|
|
75
75
|
id: "my-chat",
|
|
76
|
-
tools
|
|
77
|
-
run: async ({ messages, tools, signal }) => {
|
|
76
|
+
tools,
|
|
77
|
+
run: async ({ messages, tools, signal, streamText }) => {
|
|
78
78
|
return streamText({
|
|
79
79
|
model: anthropic("claude-sonnet-4-5"),
|
|
80
80
|
messages,
|
|
@@ -90,190 +90,192 @@ Declaring `tools` on the config (and reading them back from the payload) is the
|
|
|
90
90
|
|
|
91
91
|
## Frontend: render the question and collect the answer
|
|
92
92
|
|
|
93
|
-
|
|
93
|
+
Render the question when its tool part reaches `input-available`. Use the AI SDK's [`lastAssistantMessageIsCompleteWithToolCalls`](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-tool-usage) helper to submit the answer automatically once every pending tool call has a result.
|
|
94
94
|
|
|
95
|
-
|
|
96
|
-
|
|
95
|
+
```tsx app/components/chat.tsx
|
|
96
|
+
"use client";
|
|
97
97
|
|
|
98
|
-
|
|
99
|
-
import {
|
|
98
|
+
import { useChat } from "@ai-sdk/react";
|
|
99
|
+
import { lastAssistantMessageIsCompleteWithToolCalls } from "ai";
|
|
100
100
|
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
|
|
101
|
+
import { mintChatAccessToken, startChatSession } from "@/app/actions";
|
|
102
|
+
import type { ChatMessage, myChat } from "@/trigger/my-chat";
|
|
101
103
|
|
|
102
|
-
function ChatView({ chatId }: { chatId: string }) {
|
|
103
|
-
const transport = useTriggerChatTransport({
|
|
104
|
+
export function ChatView({ chatId }: { chatId: string }) {
|
|
105
|
+
const transport = useTriggerChatTransport<typeof myChat>({
|
|
104
106
|
task: "my-chat",
|
|
105
107
|
accessToken: ({ chatId }) => mintChatAccessToken(chatId),
|
|
106
108
|
startSession: ({ chatId, clientData }) =>
|
|
107
109
|
startChatSession({ chatId, clientData }),
|
|
108
110
|
});
|
|
109
|
-
const { messages, sendMessage, addToolOutput } = useChat({
|
|
111
|
+
const { messages, sendMessage, addToolOutput, status } = useChat<ChatMessage>({
|
|
110
112
|
id: chatId,
|
|
111
113
|
transport,
|
|
112
114
|
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
|
|
113
115
|
});
|
|
116
|
+
const busy = status === "submitted" || status === "streaming";
|
|
114
117
|
|
|
115
118
|
return (
|
|
116
119
|
<>
|
|
117
|
-
{messages.map((
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
key={
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
120
|
+
{messages.map((message) => (
|
|
121
|
+
<div key={message.id}>
|
|
122
|
+
{message.parts.map((part, i) => {
|
|
123
|
+
if (part.type === "tool-askUser" && part.state === "input-available") {
|
|
124
|
+
return (
|
|
125
|
+
<fieldset key={part.toolCallId} disabled={busy}>
|
|
126
|
+
<legend>{part.input.question}</legend>
|
|
127
|
+
{part.input.options.map((option) => (
|
|
128
|
+
<button
|
|
129
|
+
key={option.id}
|
|
130
|
+
type="button"
|
|
131
|
+
onClick={() =>
|
|
132
|
+
addToolOutput({
|
|
133
|
+
tool: "askUser",
|
|
134
|
+
toolCallId: part.toolCallId,
|
|
135
|
+
output: { optionId: option.id, label: option.label },
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
>
|
|
139
|
+
{option.label}
|
|
140
|
+
{option.description && <span> {option.description}</span>}
|
|
141
|
+
</button>
|
|
142
|
+
))}
|
|
143
|
+
</fieldset>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
if (part.type === "text") return <p key={i}>{part.text}</p>;
|
|
147
|
+
return null;
|
|
148
|
+
})}
|
|
149
|
+
</div>
|
|
150
|
+
))}
|
|
151
|
+
<button disabled={busy} onClick={() => sendMessage({ text: "Help me choose a database." })}>
|
|
152
|
+
Start a conversation
|
|
153
|
+
</button>
|
|
139
154
|
</>
|
|
140
155
|
);
|
|
141
156
|
}
|
|
142
157
|
```
|
|
143
158
|
|
|
144
|
-
`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
|
|
159
|
+
`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.
|
|
145
160
|
|
|
146
|
-
##
|
|
161
|
+
## Choose from tool results
|
|
147
162
|
|
|
148
|
-
|
|
163
|
+
When a tool returns several candidates, run the search first, then ask the user to choose one before taking action. For example, a search might find several matching contacts, files, or brands.
|
|
149
164
|
|
|
150
|
-
|
|
165
|
+
Keep the search, selection, and action as separate tool calls:
|
|
151
166
|
|
|
152
|
-
|
|
167
|
+
1. A search tool with `execute` returns candidates and saves them under a `searchId`.
|
|
168
|
+
2. A selection tool without `execute` asks the frontend to show those candidates.
|
|
169
|
+
3. An action tool with `execute` uses the selected ID after checking it on the server.
|
|
153
170
|
|
|
154
|
-
```ts
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
}
|
|
166
|
-
};
|
|
171
|
+
```ts trigger/tools/select-result.ts
|
|
172
|
+
import { tool } from "ai";
|
|
173
|
+
import { z } from "zod";
|
|
174
|
+
|
|
175
|
+
export const selectResult = tool({
|
|
176
|
+
description:
|
|
177
|
+
"Ask the user to choose from a completed search. " +
|
|
178
|
+
"Copy searchId from the search result and wait for their answer.",
|
|
179
|
+
inputSchema: z.object({ searchId: z.string() }),
|
|
180
|
+
outputSchema: z.object({ selectedId: z.string().nullable() }),
|
|
181
|
+
});
|
|
167
182
|
```
|
|
168
183
|
|
|
169
|
-
|
|
170
|
-
`finishReason` is only undefined for manual `chat.pipe()` flows or aborted streams. For the common `run() → return streamText(...)` pattern it's always populated.
|
|
171
|
-
</Note>
|
|
184
|
+
Render this tool's `input-available` part using the same approach as `askUser` above. Load the candidates from the saved search, display their original labels, and call `addToolOutput` with the selected ID. Return `{ selectedId: null }` for a "None of these" option. If a search has no results, ask for a different query before requesting a selection.
|
|
172
185
|
|
|
173
|
-
|
|
186
|
+
Save search results durably and scope them to the authorized conversation so the picker still works after a reload or continuation. Use stable candidate IDs; names can be ambiguous.
|
|
174
187
|
|
|
175
|
-
|
|
188
|
+
Before executing the action, read the completed selection from `chat.history`, validate its output, and check that the requested ID matches the user's answer and belongs to that saved search. A null answer must prevent the action. Enforce these checks in server code; tool descriptions alone don't enforce the choice.
|
|
176
189
|
|
|
177
|
-
|
|
178
|
-
const pending = chat.history.getPendingToolCalls();
|
|
179
|
-
// [{ toolCallId, toolName, messageId }]
|
|
180
|
-
```
|
|
190
|
+
Use the selection's `toolCallId`, scoped to the chat, as the action's idempotency key. That keeps retries of the same choice from creating duplicate work, even if the model makes another action call. If the next step must be the action, use `prepareStep` to set `toolChoice` on the first model step after a valid selection.
|
|
181
191
|
|
|
182
|
-
|
|
192
|
+
You can also require approval on the action tool. The selection supplies an input; the approval confirms whether to execute with that input.
|
|
183
193
|
|
|
184
|
-
|
|
194
|
+
## Approve a tool before execution
|
|
185
195
|
|
|
186
|
-
|
|
196
|
+
With **AI SDK 6 or later**, set `needsApproval: true` on a tool that has an `execute` function. The SDK requests approval before calling `execute`. Approving the call runs it on the next turn; denying it skips execution.
|
|
187
197
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
errorText: r.errorText, // set only for output-error parts
|
|
200
|
-
});
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
return incomingMessages;
|
|
204
|
-
},
|
|
198
|
+
```ts trigger/tools/send-email.ts
|
|
199
|
+
import { tool } from "ai";
|
|
200
|
+
import { z } from "zod";
|
|
201
|
+
import { sendEmail } from "@/lib/email";
|
|
202
|
+
|
|
203
|
+
export const sendEmailTool = tool({
|
|
204
|
+
description: "Send an email after the user approves.",
|
|
205
|
+
inputSchema: z.object({ to: z.string(), subject: z.string(), body: z.string() }),
|
|
206
|
+
needsApproval: true,
|
|
207
|
+
execute: async (input) => sendEmail(input),
|
|
208
|
+
});
|
|
205
209
|
```
|
|
206
210
|
|
|
207
|
-
`
|
|
211
|
+
`sendEmail` is your application's email function. Keep authorization and idempotency checks there. Declare the tool on the agent and pass it to the `streamText` supplied to `run`, as in the question example.
|
|
208
212
|
|
|
209
|
-
|
|
213
|
+
On the frontend, render approve/deny buttons for `approval-requested` parts. Call `addToolApprovalResponse` with `part.approval.id` and the `approved` boolean, and use `lastAssistantMessageIsCompleteWithApprovalResponses` for automatic submission. The [frontend approval example](/ai-chat/frontend#tool-approvals) shows the full wiring.
|
|
210
214
|
|
|
211
|
-
|
|
215
|
+
For conditional approval, [`needsApproval` also accepts a function](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling#dynamic-approval) that checks the tool input. Approval happens before `execute`, so a tool that searches inside `execute` can't use that approval prompt to display its search results. Run the search first, collect the selection, then call the action tool.
|
|
212
216
|
|
|
213
|
-
|
|
217
|
+
If your agent uses both questions and approvals, combine the helpers:
|
|
214
218
|
|
|
215
|
-
|
|
219
|
+
```ts app/components/chat.tsx
|
|
220
|
+
import {
|
|
221
|
+
lastAssistantMessageIsCompleteWithApprovalResponses,
|
|
222
|
+
lastAssistantMessageIsCompleteWithToolCalls,
|
|
223
|
+
type UIMessage,
|
|
224
|
+
} from "ai";
|
|
216
225
|
|
|
217
|
-
|
|
226
|
+
export function sendAutomaticallyWhen({ messages }: { messages: UIMessage[] }) {
|
|
227
|
+
return (
|
|
228
|
+
lastAssistantMessageIsCompleteWithToolCalls({ messages }) ||
|
|
229
|
+
lastAssistantMessageIsCompleteWithApprovalResponses({ messages })
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
```
|
|
218
233
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
234
|
+
## Detecting a paused turn in `onTurnComplete`
|
|
235
|
+
|
|
236
|
+
Use `chat.history.getPendingToolCalls()` to find unanswered tool calls on the latest assistant message:
|
|
237
|
+
|
|
238
|
+
```ts trigger/my-chat.ts
|
|
239
|
+
onTurnComplete: async ({ stopped }) => {
|
|
240
|
+
if (stopped) return;
|
|
241
|
+
const pending = chat.history.getPendingToolCalls();
|
|
242
|
+
if (pending.length > 0) {
|
|
243
|
+
console.log("Waiting for user input", pending);
|
|
244
|
+
}
|
|
225
245
|
},
|
|
226
246
|
```
|
|
227
247
|
|
|
228
|
-
|
|
248
|
+
Each entry contains `toolCallId`, `toolName`, and `messageId`. Approval requests use a separate state: inspect `responseMessage.parts` for `approval-requested` when tracking approvals.
|
|
229
249
|
|
|
230
|
-
|
|
250
|
+
`finishReason === "tool-calls"` describes why the model stopped. It can also occur when a step limit ends a loop of server-executed tools, so check the tool states before treating it as a human pause. Check `stopped` separately for interrupted turns.
|
|
231
251
|
|
|
232
|
-
|
|
252
|
+
### Acting once per net-new tool result
|
|
233
253
|
|
|
234
|
-
|
|
235
|
-
onTurnComplete: async ({ chatId, responseMessage, finishReason, uiMessages }) => {
|
|
236
|
-
if (!responseMessage) return;
|
|
254
|
+
`chat.history.extractNewToolResults(message)` returns resolved tool parts whose `toolCallId` isn't already resolved in the current history. Call it before the incoming message is merged. By `onTurnComplete`, the result is already in history and the helper returns `[]`.
|
|
237
255
|
|
|
238
|
-
|
|
239
|
-
// Paused — save a checkpoint
|
|
240
|
-
await db.turnCheckpoint.create({
|
|
241
|
-
data: {
|
|
242
|
-
chatId,
|
|
243
|
-
messageId: responseMessage.id,
|
|
244
|
-
parts: responseMessage.parts,
|
|
245
|
-
kind: "partial",
|
|
246
|
-
},
|
|
247
|
-
});
|
|
248
|
-
} else {
|
|
249
|
-
// Completed — save a sibling with the merged full message
|
|
250
|
-
await db.turnCheckpoint.create({
|
|
251
|
-
data: {
|
|
252
|
-
chatId,
|
|
253
|
-
messageId: responseMessage.id,
|
|
254
|
-
parts: responseMessage.parts,
|
|
255
|
-
kind: "final",
|
|
256
|
-
},
|
|
257
|
-
});
|
|
258
|
-
}
|
|
256
|
+
This filters results against the current transcript; durable side effects still need an idempotency key. Use `chatId` and `toolCallId` to deduplicate a sync or audit write across retries. See [Tool result auditing](/ai-chat/patterns/tool-result-auditing) for existing `hydrateMessages` integrations.
|
|
259
257
|
|
|
260
|
-
|
|
261
|
-
await db.chat.update({
|
|
262
|
-
where: { id: chatId },
|
|
263
|
-
data: { messages: uiMessages },
|
|
264
|
-
});
|
|
265
|
-
};
|
|
266
|
-
```
|
|
258
|
+
## Persistence: one message vs one record per pause
|
|
267
259
|
|
|
268
|
-
|
|
260
|
+
The default [transcript storage](/ai-chat/transcript-storage) saves the pending call and its resolved result. Use a storage adapter if you need the conversation in your own database.
|
|
261
|
+
|
|
262
|
+
The pause and continuation share the assistant message ID. Upsert by `message.id` so the resolved message replaces the pending version. For an audit trail, keep separate snapshots alongside that canonical message and record whether it has pending questions, approval requests, or a stopped response.
|
|
269
263
|
|
|
270
264
|
## Multi-pause turns
|
|
271
265
|
|
|
272
|
-
A
|
|
266
|
+
A conversation can ask another question after receiving an answer. Each pause and continuation fires `onTurnComplete`; inspect the message's tool states at each callback. The merged assistant message includes the answers collected so far.
|
|
267
|
+
|
|
268
|
+
## Verify the flow
|
|
269
|
+
|
|
270
|
+
Send a request that needs clarification. Confirm that the question appears, choose an option, and check that the agent continues without another click on Send. The question's tool part should change from `input-available` to `output-available`.
|
|
271
|
+
|
|
272
|
+
For a selection from search results, check that only the selected item is used. Choosing "None of these" must leave the action unexecuted. Reload a pending selection and confirm that the same candidates appear.
|
|
273
|
+
|
|
274
|
+
For an approval tool, deny the first request and confirm that `execute` doesn't run. Approve a fresh request and confirm that it runs with the displayed inputs.
|
|
273
275
|
|
|
274
|
-
##
|
|
276
|
+
## Common mistakes
|
|
275
277
|
|
|
276
|
-
-
|
|
277
|
-
-
|
|
278
|
-
-
|
|
279
|
-
-
|
|
278
|
+
- Give question tools no `execute` function. For tools that execute after approval, keep `execute` and set `needsApproval`.
|
|
279
|
+
- `addToolOutput` updates local state. Configure `sendAutomaticallyWhen` or explicitly send the updated message to start the next turn.
|
|
280
|
+
- Only show interactive controls for pending calls, and disable them while a request is streaming or submitted.
|
|
281
|
+
- To add custom response parts, use `chat.response.append()` in `onBeforeTurnComplete` while the stream is open. Treat `responseMessage` in `onTurnComplete` as a snapshot.
|
|
@@ -1,23 +1,24 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: "Tool result auditing"
|
|
3
3
|
sidebarTitle: "Tool result auditing"
|
|
4
|
-
description: "
|
|
4
|
+
description: "Find new tool results before they merge into chat history, and deduplicate audit writes with a durable idempotency key."
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Use `chat.history.extractNewToolResults(message)` to find incoming tool results that aren't resolved in the current transcript. Make the resulting audit write idempotent in your database so retries can't create duplicate records.
|
|
8
8
|
|
|
9
|
-
-
|
|
10
|
-
- **Billing** — charge per tool invocation.
|
|
11
|
-
- **Notifications** — alert downstream systems when a specific tool resolves.
|
|
12
|
-
- **Search-index updates** — reflect tool outputs into a derived store.
|
|
9
|
+
The helper applies to [human-in-the-loop](/ai-chat/patterns/human-in-the-loop) answers sent with `addToolOutput`. It compares the incoming message with the current history; it doesn't track whether an external write succeeded.
|
|
13
10
|
|
|
14
|
-
|
|
11
|
+
<Note>
|
|
12
|
+
The `hydrateMessages` examples below apply to existing integrations. That hook is deprecated. For new conversation persistence, use [transcript storage](/ai-chat/transcript-storage).
|
|
13
|
+
</Note>
|
|
15
14
|
|
|
16
15
|
## The pattern
|
|
17
16
|
|
|
18
17
|
```ts
|
|
19
18
|
import { chat } from "@trigger.dev/sdk/ai";
|
|
20
19
|
import { auditLog } from "@/lib/audit";
|
|
20
|
+
import { db } from "@/lib/db";
|
|
21
|
+
import { anthropic } from "@ai-sdk/anthropic";
|
|
21
22
|
|
|
22
23
|
export const myChat = chat.agent({
|
|
23
24
|
id: "my-chat",
|
|
@@ -35,7 +36,7 @@ export const myChat = chat.agent({
|
|
|
35
36
|
}
|
|
36
37
|
return await db.getMessages(chatId);
|
|
37
38
|
},
|
|
38
|
-
run: async ({ messages, signal }) => {
|
|
39
|
+
run: async ({ messages, signal, streamText }) => {
|
|
39
40
|
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
|
|
40
41
|
},
|
|
41
42
|
});
|
|
@@ -43,10 +44,10 @@ export const myChat = chat.agent({
|
|
|
43
44
|
|
|
44
45
|
The hook fires per turn. `incomingMessages` is the new wire message (0-or-1-length, see [v4.5 wire format change](/ai-chat/upgrade-guide#v45-wire-format-change)). For each new tool result on that message, write one audit row. Then return the canonical chain from your DB.
|
|
45
46
|
|
|
46
|
-
`extractNewToolResults` compares the message against the current `chat.history` chain and returns only tool parts whose `toolCallId` is **not** already resolved.
|
|
47
|
+
`extractNewToolResults` compares the message against the current `chat.history` chain and returns only tool parts whose `toolCallId` is **not** already resolved. For results already present in that history:
|
|
47
48
|
|
|
48
|
-
- A re-emitted message
|
|
49
|
-
- A
|
|
49
|
+
- A re-emitted message with the same resolved `toolCallId` returns `[]`.
|
|
50
|
+
- A new tool result on a known assistant message is returned.
|
|
50
51
|
- A first-time tool result returns the full set.
|
|
51
52
|
|
|
52
53
|
## Why `hydrateMessages` is the right hook
|
|
@@ -54,13 +55,13 @@ The hook fires per turn. `incomingMessages` is the new wire message (0-or-1-leng
|
|
|
54
55
|
The pattern works in any pre-merge callback, but `hydrateMessages` is the canonical spot for two reasons:
|
|
55
56
|
|
|
56
57
|
1. **It fires before the runtime merges** the incoming message into the accumulator. Once merged, the tool results are already on the chain, and `extractNewToolResults` returns `[]` for them.
|
|
57
|
-
2. **It
|
|
58
|
+
2. **It fires on each turn**, including turns that receive a user's answer through `addToolOutput`.
|
|
58
59
|
|
|
59
60
|
By the time `onTurnComplete` fires, the chain already contains `responseMessage`, so calling `extractNewToolResults(responseMessage)` there returns `[]`. Don't put audit logging there for the resolution path.
|
|
60
61
|
|
|
61
|
-
##
|
|
62
|
+
## Audit server-executed tools in `onTurnComplete`
|
|
62
63
|
|
|
63
|
-
If you don't use `hydrateMessages`, the runtime's snapshot+replay path handles persistence. You can still audit the agent's **own** tool executions in `onTurnComplete`
|
|
64
|
+
If you don't use `hydrateMessages`, the runtime's snapshot+replay path handles persistence. You can still audit the agent's **own** tool executions in `onTurnComplete` by iterating the emitted parts and using an idempotent audit writer:
|
|
64
65
|
|
|
65
66
|
```ts
|
|
66
67
|
onTurnComplete: async ({ chatId, newUIMessages }) => {
|
|
@@ -87,23 +88,23 @@ onTurnComplete: async ({ chatId, newUIMessages }) => {
|
|
|
87
88
|
},
|
|
88
89
|
```
|
|
89
90
|
|
|
90
|
-
`newUIMessages`
|
|
91
|
+
`newUIMessages` contains the messages this turn produced. Retries and merged assistant messages can expose a tool result again, so deduplicate these writes in your database too.
|
|
91
92
|
|
|
92
|
-
This works for tools the agent itself calls (no HITL pause). For HITL flows where the user resolves a tool with `addToolOutput`, the resolution arrives on the **next** turn's wire message, not in `newUIMessages` of the resolving turn
|
|
93
|
+
This works for tools the agent itself calls (no HITL pause). For HITL flows where the user resolves a tool with `addToolOutput`, the resolution arrives on the **next** turn's wire message, not in `newUIMessages` of the resolving turn. Use `hydrateMessages` for those in existing integrations.
|
|
93
94
|
|
|
94
95
|
## Idempotency at the storage layer
|
|
95
96
|
|
|
96
|
-
Even with `extractNewToolResults`, transient failures (e.g. an audit-log POST that times out and is retried) can produce duplicates. Make the audit-log writer idempotent on `toolCallId`:
|
|
97
|
+
Even with `extractNewToolResults`, transient failures (e.g. an audit-log POST that times out and is retried) can produce duplicates. Make the audit-log writer idempotent on `(chatId, toolCallId)`:
|
|
97
98
|
|
|
98
99
|
```ts
|
|
99
100
|
await auditLog.upsert({
|
|
100
|
-
where: { toolCallId: r.toolCallId },
|
|
101
|
+
where: { chatId_toolCallId: { chatId, toolCallId: r.toolCallId } },
|
|
101
102
|
create: { /* ... */ },
|
|
102
103
|
update: { /* timestamp, retry count, etc. */ },
|
|
103
104
|
});
|
|
104
105
|
```
|
|
105
106
|
|
|
106
|
-
`toolCallId
|
|
107
|
+
A replay of the same tool call keeps its `toolCallId`. A new model-generated invocation can have a new ID; use an application operation ID as well when separate calls must refer to the same external action.
|
|
107
108
|
|
|
108
109
|
## What `extractNewToolResults` returns
|
|
109
110
|
|
|
@@ -116,17 +117,17 @@ type ChatNewToolResult = {
|
|
|
116
117
|
};
|
|
117
118
|
```
|
|
118
119
|
|
|
119
|
-
Tool parts in `input-available` state (the model called the tool but it hasn't resolved yet)
|
|
120
|
+
Tool parts in `input-available` state (the model called the tool but it hasn't resolved yet) aren't returned. The helper returns resolved results.
|
|
120
121
|
|
|
121
122
|
## Combining with HITL
|
|
122
123
|
|
|
123
|
-
[Human-in-the-loop](/ai-chat/patterns/human-in-the-loop) tools pause the turn waiting for `addToolOutput` from the frontend. When the user submits, the wire message carries an updated assistant message with the tool now in `output-available` state. `extractNewToolResults` against that message returns the
|
|
124
|
+
[Human-in-the-loop](/ai-chat/patterns/human-in-the-loop) tools pause the turn waiting for `addToolOutput` from the frontend. When the user submits, the wire message carries an updated assistant message with the tool now in `output-available` state. `extractNewToolResults` against that message returns the newly resolved tool. An idempotent audit writer keeps one row per resolution:
|
|
124
125
|
|
|
125
126
|
```ts
|
|
126
127
|
hydrateMessages: async ({ chatId, incomingMessages }) => {
|
|
127
128
|
for (const msg of incomingMessages) {
|
|
128
129
|
for (const r of chat.history.extractNewToolResults(msg)) {
|
|
129
|
-
//
|
|
130
|
+
// Deduplicate the write by chatId and toolCallId.
|
|
130
131
|
await auditLog.record({ chatId, /* ... */ });
|
|
131
132
|
}
|
|
132
133
|
}
|
|
@@ -134,11 +135,11 @@ hydrateMessages: async ({ chatId, incomingMessages }) => {
|
|
|
134
135
|
}
|
|
135
136
|
```
|
|
136
137
|
|
|
137
|
-
|
|
138
|
+
See the [HITL pattern's net-new-tool-result section](/ai-chat/patterns/human-in-the-loop#acting-once-per-net-new-tool-result).
|
|
138
139
|
|
|
139
140
|
## See also
|
|
140
141
|
|
|
141
|
-
- [`chat.history`](/ai-chat/backend#chat-history)
|
|
142
|
-
- [Human-in-the-loop](/ai-chat/patterns/human-in-the-loop)
|
|
143
|
-
- [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages)
|
|
144
|
-
- [Persistence and replay](/ai-chat/patterns/persistence-and-replay)
|
|
142
|
+
- [`chat.history`](/ai-chat/backend#chat-history): full reference for `extractNewToolResults`, `getPendingToolCalls`, `getResolvedToolCalls`
|
|
143
|
+
- [Human-in-the-loop](/ai-chat/patterns/human-in-the-loop): the pattern this auditing hook complements
|
|
144
|
+
- [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages): where pre-merge auditing lives
|
|
145
|
+
- [Persistence and replay](/ai-chat/patterns/persistence-and-replay): how the runtime rebuilds chains, and why `extractNewToolResults` works against them
|