Skip to content

Commit 84c1434

Browse files
committed
fix(sdk): chat HITL continuations no longer break the next LLM call
1 parent 61ca40b commit 84c1434

8 files changed

Lines changed: 888 additions & 29 deletions

File tree

.changeset/chat-slim-wire-merge.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
---
4+
5+
Fix `chat.agent` HITL continuations on reasoning-heavy turns. Two changes that work together:
6+
7+
- The per-turn merge now overlays the wire copy's tool-part state advancement onto the agent's existing chain — `state` + the matching resolution field (`output` / `errorText` / `approval`) come from the wire, everything else (text, reasoning, tool `input`, provider metadata) stays whatever the snapshot or `hydrateMessages` returned. Previously a full-message replace overwrote those fields with whatever the client shipped, so a slimmed wire copy landed a tool call with no `arguments` on the next LLM call. Covers `output-available` / `output-error` (HITL `addToolOutput`) and `approval-responded` / `output-denied` (approval flow).
8+
- `TriggerChatTransport.sendMessages` and `AgentChat.sendRaw` now slim assistant messages that carry advanced tool parts. The wire payload is just `{ id, role, parts: [<state + resolution field>] }` for `submit-message` continuations; everything else passes through. Reasoning blobs and full tool inputs no longer ride the wire on every `addToolOutput` / `addToolApproveResponse`, so continuation payloads stay well under the `.in/append` cap on long agent loops.
9+
10+
Note: `onValidateMessages` receives the slim wire on HITL turns. If you call `validateUIMessages` from `ai` against the full `messages` array it will reject the slim assistant; filter to user messages (or skip on HITL turns) — see the updated docstring on `onValidateMessages` for the recommended pattern.
11+
12+
Net effect: `chat.addToolOutput(...)` / `chat.addToolApproveResponse(...)` on multi-step reasoning agents (OpenAI Responses with `store: false`, Anthropic extended thinking, etc.) no longer blows the cap and no longer corrupts the LLM input.

packages/trigger-sdk/src/v3/ai-shared.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,3 +198,94 @@ export type InferChatUIMessage<TTask extends AnyTask> = TTask extends Task<
198198
>
199199
? TUIM
200200
: UIMessage;
201+
202+
/**
203+
* Tool-part states that the client advances and ships back over the wire.
204+
* Covers HITL `addToolOutput` (output-available / output-error) and the
205+
* approval flow (approval-responded / output-denied). `input-streaming` /
206+
* `input-available` / `approval-requested` are server-emitted only — if
207+
* we see them on the wire we treat them as no-ops and skip the slim/merge.
208+
*/
209+
function isWireAdvanceableToolState(
210+
state: unknown
211+
): state is "output-available" | "output-error" | "approval-responded" | "output-denied" {
212+
return (
213+
state === "output-available" ||
214+
state === "output-error" ||
215+
state === "approval-responded" ||
216+
state === "output-denied"
217+
);
218+
}
219+
220+
/** Whether a tool-UI part is a static (`tool-${name}`) or dynamic tool. */
221+
function isToolPartType(type: unknown): boolean {
222+
return typeof type === "string" && (type.startsWith("tool-") || type === "dynamic-tool");
223+
}
224+
225+
/**
226+
* Slim an outgoing assistant message before it ships on `submit-message`.
227+
*
228+
* When the client calls `addToolOutput(...)` to resolve a HITL tool (or
229+
* `addToolApproveResponse(...)` to approve/deny one), the AI SDK turns
230+
* it into a `submit-message` whose `messages.at(-1)` is the existing
231+
* assistant message with the new state stitched onto a single tool
232+
* part. On a reasoning-heavy multi-step turn, that full assistant
233+
* message can be 600 KB – 1 MB (encrypted reasoning blobs, reasoning
234+
* text, full tool `input` JSON, prior tool outputs) — well over the
235+
* `.in/append` cap.
236+
*
237+
* The agent runtime only consumes the wire-advanced fields of those
238+
* tool parts (state + output / errorText / approval). Everything else
239+
* (text, reasoning, tool `input`) is rebuilt server-side from the
240+
* durable snapshot or `hydrateMessages`. So we drop everything but
241+
* the advanced tool parts here, and reduce those to just the fields
242+
* the server overlays.
243+
*
244+
* The slim only fires when the assistant message carries at least one
245+
* wire-advanceable tool part. Plain assistant resends (no resolved /
246+
* approval-responded tool) and non-assistant messages pass through
247+
* untouched.
248+
*
249+
* Pairs with the per-turn merge on the agent side
250+
* (`mergeIncomingIntoHydrated` in `ai.ts`).
251+
*/
252+
export function slimSubmitMessageForWire<TMsg extends UIMessage | undefined>(
253+
message: TMsg
254+
): TMsg {
255+
if (!message) return message;
256+
if (message.role !== "assistant") return message;
257+
const parts = (message.parts ?? []) as any[];
258+
const advancedToolParts = parts.filter(
259+
(p) =>
260+
p &&
261+
typeof p === "object" &&
262+
isToolPartType(p.type) &&
263+
isWireAdvanceableToolState(p.state)
264+
);
265+
if (advancedToolParts.length === 0) return message;
266+
const slimParts = advancedToolParts.map((p: any) => {
267+
const base: Record<string, unknown> = {
268+
type: p.type,
269+
toolCallId: p.toolCallId,
270+
state: p.state,
271+
};
272+
if (p.type === "dynamic-tool" && typeof p.toolName === "string") {
273+
base.toolName = p.toolName;
274+
}
275+
if (p.state === "output-available") {
276+
base.output = p.output;
277+
if (p.approval !== undefined) base.approval = p.approval;
278+
} else if (p.state === "output-error") {
279+
if (p.errorText !== undefined) base.errorText = p.errorText;
280+
if (p.approval !== undefined) base.approval = p.approval;
281+
} else if (p.state === "approval-responded" || p.state === "output-denied") {
282+
if (p.approval !== undefined) base.approval = p.approval;
283+
}
284+
return base;
285+
});
286+
return {
287+
id: message.id,
288+
role: message.role,
289+
parts: slimParts,
290+
} as unknown as TMsg;
291+
}

packages/trigger-sdk/src/v3/ai.ts

Lines changed: 153 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2128,6 +2128,103 @@ function extractNewToolResultsFromHistory(
21282128
return out;
21292129
}
21302130

2131+
/**
2132+
* Per-turn merge of an incoming wire `UIMessage` onto the matching entry
2133+
* a `hydrateMessages` hook (or the default accumulator) provides. Used
2134+
* to fold tool-state advances from the client into the agent's
2135+
* authoritative chain without trusting the wire copy for fields the
2136+
* LLM consumes.
2137+
*
2138+
* `hydrated` is treated as the source of truth for everything outside
2139+
* tool-state advancement: text, reasoning blobs, provider metadata,
2140+
* and tool `input` all stay as hydrated had them. We only overlay
2141+
* tool parts whose incoming state is wire-advanced — `output-available`
2142+
* / `output-error` (HITL `addToolOutput`) or `approval-responded` /
2143+
* `output-denied` (approval flow) — and only the corresponding
2144+
* resolution fields (`output` / `errorText` / `approval`). Hydrated
2145+
* `input` and everything else stay put.
2146+
*
2147+
* Without this, a slim wire copy (which `TriggerChatTransport` /
2148+
* `AgentChat.sendRaw` ship by default on HITL continuations) would
2149+
* clobber the hydrated assistant — the next LLM call would receive a
2150+
* tool call with no `input` and 4xx.
2151+
*
2152+
* @internal
2153+
*/
2154+
function mergeIncomingIntoHydrated<TMsg extends UIMessage>(
2155+
hydrated: TMsg,
2156+
incoming: UIMessage
2157+
): TMsg {
2158+
const incomingAdvancedByCallId = new Map<string, any>();
2159+
for (const part of (incoming.parts ?? []) as any[]) {
2160+
if (!isToolUIPart(part)) continue;
2161+
const toolCallId = part.toolCallId;
2162+
if (typeof toolCallId !== "string" || toolCallId.length === 0) continue;
2163+
if (!isWireAdvanceableToolState(part.state)) continue;
2164+
incomingAdvancedByCallId.set(toolCallId, part);
2165+
}
2166+
2167+
if (incomingAdvancedByCallId.size === 0) return hydrated;
2168+
2169+
let mutated = false;
2170+
const hydratedParts = (hydrated.parts ?? []) as any[];
2171+
const mergedParts = hydratedParts.map((part) => {
2172+
if (!isToolUIPart(part)) return part;
2173+
const toolCallId = part.toolCallId;
2174+
if (typeof toolCallId !== "string" || toolCallId.length === 0) return part;
2175+
const incomingPart = incomingAdvancedByCallId.get(toolCallId);
2176+
if (!incomingPart) return part;
2177+
// Hydrated already carries a resolved state for this call — treat
2178+
// it as authoritative and ignore the wire copy. Repeat sends of the
2179+
// same answer (replay, retry) are no-ops.
2180+
if (isResolvedToolState(part.state)) return part;
2181+
mutated = true;
2182+
if (incomingPart.state === "output-available") {
2183+
return {
2184+
...part,
2185+
state: incomingPart.state,
2186+
output: incomingPart.output,
2187+
...(incomingPart.approval !== undefined ? { approval: incomingPart.approval } : {}),
2188+
};
2189+
}
2190+
if (incomingPart.state === "output-error") {
2191+
return {
2192+
...part,
2193+
state: incomingPart.state,
2194+
errorText: incomingPart.errorText,
2195+
...(incomingPart.approval !== undefined ? { approval: incomingPart.approval } : {}),
2196+
};
2197+
}
2198+
// approval-responded / output-denied — overlay state + approval.
2199+
return {
2200+
...part,
2201+
state: incomingPart.state,
2202+
...(incomingPart.approval !== undefined ? { approval: incomingPart.approval } : {}),
2203+
};
2204+
});
2205+
2206+
if (!mutated) return hydrated;
2207+
return { ...hydrated, parts: mergedParts };
2208+
}
2209+
2210+
/**
2211+
* Mirror of `slimSubmitMessageForWire`'s predicate. Kept here so the
2212+
* agent runtime doesn't have to import from `ai-shared.ts` for a
2213+
* one-liner. See that file for the full state-machine docs.
2214+
*
2215+
* @internal
2216+
*/
2217+
function isWireAdvanceableToolState(
2218+
state: unknown
2219+
): state is "output-available" | "output-error" | "approval-responded" | "output-denied" {
2220+
return (
2221+
state === "output-available" ||
2222+
state === "output-error" ||
2223+
state === "approval-responded" ||
2224+
state === "output-denied"
2225+
);
2226+
}
2227+
21312228
/**
21322229
* Imperative API for reading and modifying the accumulated message history.
21332230
*
@@ -3876,7 +3973,14 @@ export type HydrateMessagesEvent<TClientData = unknown, TUIM extends UIMessage =
38763973
* Event passed to the `onValidateMessages` callback.
38773974
*/
38783975
export type ValidateMessagesEvent<TUIM extends UIMessage = UIMessage> = {
3879-
/** The incoming UI messages for this turn (after cleanup of aborted tool parts). */
3976+
/**
3977+
* The incoming UI messages for this turn (after cleanup of aborted tool parts).
3978+
*
3979+
* For HITL continuations the assistant entry is slim — `state` + `output` /
3980+
* `errorText` / `approval` only, no `input` or other parts. Don't pass the
3981+
* full `messages` array to `validateUIMessages` from `ai`; filter to user
3982+
* messages (or your own subset) first.
3983+
*/
38803984
messages: TUIM[];
38813985
/** The unique identifier for the chat session. */
38823986
chatId: string;
@@ -4372,8 +4476,13 @@ export type ChatAgentOptions<
43724476
*
43734477
* Return the validated messages array. Throw to abort the turn with an error.
43744478
*
4375-
* This is the right place to call the AI SDK's `validateUIMessages` to catch
4376-
* malformed messages from storage or untrusted input before they reach the model.
4479+
* This is the right place to call the AI SDK's `validateUIMessages` on fresh
4480+
* user input. For HITL continuations (`addToolOutput` /
4481+
* `addToolApproveResponse`), the wire carries a slim assistant message — only
4482+
* the resolved tool parts, with `state` + `output` / `errorText` / `approval`
4483+
* and no `input`. `validateUIMessages` against the AI SDK schema rejects
4484+
* that shape, so filter to user messages (or skip validation entirely) on
4485+
* those turns.
43774486
*
43784487
* @example
43794488
* ```ts
@@ -4382,7 +4491,11 @@ export type ChatAgentOptions<
43824491
* chat.agent({
43834492
* id: "my-chat",
43844493
* onValidateMessages: async ({ messages }) => {
4385-
* return validateUIMessages({ messages, tools: chatTools });
4494+
* const userMessages = messages.filter((m) => m.role === "user");
4495+
* if (userMessages.length > 0) {
4496+
* await validateUIMessages({ messages: userMessages, tools: chatTools });
4497+
* }
4498+
* return messages;
43864499
* },
43874500
* run: async ({ messages }) => {
43884501
* return streamText({ model, messages, tools: chatTools });
@@ -6071,30 +6184,47 @@ function chatAgent<
60716184
}
60726185
);
60736186

6074-
// Auto-merge tool approval updates: if any incoming wire message
6075-
// has an ID that matches a hydrated message, replace it. This makes
6076-
// tool approvals work transparently with backend hydration.
6187+
// Per-turn merge of incoming wire messages onto the hydrated
6188+
// chain. Hydrated stays authoritative for text, reasoning
6189+
// blobs, provider metadata, and tool `input`; we only
6190+
// overlay tool-part state/output/errorText for tool calls
6191+
// the wire copy has just resolved. Apps that slim the wire
6192+
// copy to fit the .in/append cap (or drop fields they
6193+
// re-source from their own DB) get the hydrated copy
6194+
// through unchanged.
60776195
const merged = [...hydrated] as TUIMessage[];
60786196
for (const incoming of cleanedUIMessages) {
60796197
if (!incoming.id) continue;
60806198
const idx = merged.findIndex((m) => m.id === incoming.id);
60816199
if (idx !== -1) {
6082-
merged[idx] = incoming as TUIMessage;
6200+
merged[idx] = mergeIncomingIntoHydrated(
6201+
merged[idx]!,
6202+
incoming
6203+
) as TUIMessage;
60836204
}
60846205
}
60856206

60866207
accumulatedUIMessages = merged;
60876208
accumulatedMessages = await toModelMessages(merged);
60886209
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
60896210

6090-
// Track new messages for onTurnComplete.newUIMessages
6211+
// Track new messages for onTurnComplete.newUIMessages.
6212+
// Surface the post-merge entry when the wire copy
6213+
// matched a hydrated message — the wire copy may have
6214+
// been slimmed (HITL tool-output continuation), and
6215+
// customers expect `newUIMessages` to carry full
6216+
// content (text, reasoning, tool `input`).
60916217
if (
60926218
currentWirePayload.trigger === "submit-message" &&
60936219
cleanedUIMessages.length > 0
60946220
) {
60956221
const lastUI = cleanedUIMessages[cleanedUIMessages.length - 1]!;
6096-
turnNewUIMessages.push(lastUI);
6097-
const lastModel = (await toModelMessages([lastUI]))[0];
6222+
const mergedEntry = lastUI.id
6223+
? merged.find((m) => m.id === lastUI.id)
6224+
: undefined;
6225+
const surfaceUI = (mergedEntry ?? lastUI) as TUIMessage;
6226+
turnNewUIMessages.push(surfaceUI);
6227+
const lastModel = (await toModelMessages([surfaceUI]))[0];
60986228
if (lastModel) turnNewModelMessages.push(lastModel);
60996229
}
61006230
} else {
@@ -6121,15 +6251,17 @@ function chatAgent<
61216251
} else if (cleanedUIMessages.length > 0) {
61226252
// Submit-message (and the special-cased
61236253
// handover-prepare → submit-message rewrite earlier in
6124-
// this scope): append-or-replace-by-id for the single
6125-
// delta message.
6254+
// this scope): merge-or-append for the single delta
6255+
// message.
61266256
//
61276257
// Tool approval responses arrive as a single assistant
61286258
// message whose id collides with the existing assistant
6129-
// in the accumulator — we replace by id. The fallback
6130-
// for HITL `addToolOutput` continuations where AI SDK
6131-
// regenerates the id (TRI-9137) still applies via
6132-
// `rewriteIncomingIdViaToolCallMap`.
6259+
// in the accumulator — we merge the resolved tool-part
6260+
// resolutions onto the existing entry, keeping text,
6261+
// reasoning, and tool `input` from the prior snapshot.
6262+
// The fallback for HITL `addToolOutput` continuations
6263+
// where AI SDK regenerates the id (TRI-9137) still
6264+
// applies via `rewriteIncomingIdViaToolCallMap`.
61336265
let replaced = false;
61346266
for (const raw of cleanedUIMessages) {
61356267
let incoming = raw;
@@ -6146,7 +6278,10 @@ function chatAgent<
61466278
}
61476279
}
61486280
if (idx !== -1) {
6149-
accumulatedUIMessages[idx] = incoming as TUIMessage;
6281+
accumulatedUIMessages[idx] = mergeIncomingIntoHydrated(
6282+
accumulatedUIMessages[idx]!,
6283+
incoming
6284+
) as TUIMessage;
61506285
replaced = true;
61516286
} else {
61526287
accumulatedUIMessages.push(incoming as TUIMessage);

packages/trigger-sdk/src/v3/chat-client.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
TRIGGER_CONTROL_SUBTYPE,
2727
} from "@trigger.dev/core/v3";
2828
import type { ChatInputChunk, ChatTaskWirePayload } from "./ai-shared.js";
29+
import { slimSubmitMessageForWire } from "./ai-shared.js";
2930
import { sessions } from "./sessions.js";
3031

3132
// ─── Type inference ────────────────────────────────────────────────
@@ -406,16 +407,20 @@ export class AgentChat<TAgent = unknown> {
406407

407408
// Slim wire — at most ONE message per record. The agent rebuilds prior
408409
// history from its durable S3 snapshot + session.out replay at run
409-
// boot. `regenerate-message` omits `message` (the agent slices its own
410-
// history). See plan vivid-humming-bonbon.
410+
// boot (or `hydrateMessages` if registered).
411+
//
412+
// For `submit-message`, assistant messages carrying resolved tool parts
413+
// (HITL `addToolOutput` answers) are slimmed to just the resolution
414+
// payload — reasoning blobs, text, and tool `input` come from the
415+
// agent's authoritative chain. `regenerate-message` omits `message`.
411416
if (triggerType === "submit-message" && messages.length === 0) {
412417
throw new Error(
413418
"AgentChat.sendRaw: 'submit-message' trigger requires at least one message"
414419
);
415420
}
416421
const lastIfSubmit =
417422
triggerType === "submit-message"
418-
? (messages.at(-1) as UIMessage | undefined)
423+
? slimSubmitMessageForWire(messages.at(-1) as UIMessage | undefined)
419424
: undefined;
420425
const payload: ChatTaskWirePayload = {
421426
...(lastIfSubmit ? { message: lastIfSubmit } : {}),

0 commit comments

Comments
 (0)