Skip to content

Commit 0b4aa77

Browse files
committed
feat(sdk): run tail recovery for every chat.agent and let a transcript storage own the model's context
One condition used to decide three things at boot: whether to read the persisted transcript, whether to replay the session's output tail, and whether to replay unacknowledged input. Registering hydrateMessages switched all three off, so an app that owned its own context also lost crash recovery, and no application can rebuild the tail its dead run had already emitted. The replays and onRecoveryBoot now run for every agent; only the transcript read is skipped for hydrateMessages. The storage can now declare loadContext, which the runtime calls on every turn and action in place of the accumulated transcript, the role hydrateMessages played, while save keeps receiving every change. hydrateMessages is deprecated with a one-time warning, and configuring it together with a storage that has loadContext is an error.
1 parent 192cc15 commit 0b4aa77

3 files changed

Lines changed: 284 additions & 26 deletions

File tree

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

Lines changed: 77 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5128,6 +5128,18 @@ function isUIMessageStreamable(value: unknown): value is UIMessageStreamable {
51285128
);
51295129
}
51305130

5131+
const warnedHydrateMessagesDeprecated = new Set<string>();
5132+
function warnHydrateMessagesDeprecatedOnce(agentId: string) {
5133+
if (warnedHydrateMessagesDeprecated.has(agentId)) return;
5134+
warnedHydrateMessagesDeprecated.add(agentId);
5135+
console.warn(
5136+
`[chat.agent] \`hydrateMessages\` on "${agentId}" is deprecated. Give the agent a transcript ` +
5137+
"storage instead: `save` receives every change to the conversation and `loadContext` " +
5138+
"lets the application own the model's context, with crash recovery and durable " +
5139+
"compaction that `hydrateMessages` never had."
5140+
);
5141+
}
5142+
51315143
let warnedMissingOnAction = false;
51325144
function warnMissingOnActionOnce() {
51335145
if (warnedMissingOnAction) return;
@@ -5338,8 +5350,9 @@ export type RecoveryPendingToolCall = {
53385350
* `chat.endRun()` with no buffered user messages, fresh chat, OOM retry
53395351
* after a successful turn-complete with no in-flight tail).
53405352
*
5341-
* Does NOT fire when `hydrateMessages` is registered (the customer owns
5342-
* persistence; recovery decisions live in their own DB query).
5353+
* Fires regardless of who owns the model's context. With `hydrateMessages`
5354+
* or a storage `loadContext`, the recovered tail reaches that hook in
5355+
* `previousMessages` on the next turn.
53435356
*/
53445357
export type RecoveryBootEvent<TUIM extends UIMessage = UIMessage> = {
53455358
/** Task run context — same as `task({ run })` second-argument `ctx`. */
@@ -5409,8 +5422,9 @@ export type RecoveryBootResult<TUIM extends UIMessage = UIMessage> = {
54095422
* context, mutate its tool parts to inject synthesized results,
54105423
* collapse history, etc.
54115424
*
5412-
* Ignored when `hydrateMessages` is registered (the hydrate hook
5413-
* runs per-turn and overwrites the chain).
5425+
* With `hydrateMessages` or a storage `loadContext`, this chain is what
5426+
* the hook receives as `previousMessages` on the next turn; the hook's
5427+
* return value is the chain the model sees.
54145428
*/
54155429
chain?: TUIM[];
54165430
/**
@@ -5983,9 +5997,9 @@ export type ChatAgentOptions<
59835997
* continuation after `chat.endRun()` with no buffered user, a fresh
59845998
* chat, or an OOM retry on top of a complete snapshot.
59855999
*
5986-
* Does NOT fire when `hydrateMessages` is registered — that hook owns
5987-
* the per-turn chain and overlapping recovery decisions belong in the
5988-
* customer's DB.
6000+
* Fires regardless of who owns the model's context; a `hydrateMessages`
6001+
* hook or a storage `loadContext` receives the recovered tail in
6002+
* `previousMessages` on the next turn.
59896003
*
59906004
* Defaults (returned when the hook is omitted or returns no field):
59916005
* - With two or more in-flight users, the partial and the user it
@@ -6740,6 +6754,18 @@ function chatAgent<
67406754
...restOptions
67416755
} = options;
67426756

6757+
if (hydrateMessages) {
6758+
const storageAtDefinition = transcriptStorageOverride ?? defaultStorage;
6759+
if (typeof storageAtDefinition.loadContext === "function") {
6760+
throw new Error(
6761+
`chat.agent: "${options.id}" sets \`hydrateMessages\` and uses a transcript storage with ` +
6762+
"`loadContext`. Both would own the model's context; keep one. `hydrateMessages` is " +
6763+
"deprecated, so prefer `loadContext` on the storage."
6764+
);
6765+
}
6766+
warnHydrateMessagesDeprecatedOnce(options.id);
6767+
}
6768+
67436769
const parseClientData = clientDataSchema ? getSchemaParseFn(clientDataSchema) : undefined;
67446770
const parseAction = actionSchema ? getSchemaParseFn(actionSchema) : undefined;
67456771

@@ -6890,6 +6916,22 @@ function chatAgent<
68906916
// swallow errors internally; the agent stays available either way.
68916917
const sessionIdForSnapshot = payload.sessionId ?? payload.chatId;
68926918
const transcriptStorage = transcriptStorageOverride ?? defaultStorage;
6919+
const storageLoadContext = transcriptStorage.loadContext?.bind(transcriptStorage);
6920+
/**
6921+
* Who supplies the model's context each turn: the deprecated
6922+
* `hydrateMessages` hook, the storage's `loadContext`, or (undefined)
6923+
* the runtime's own transcript.
6924+
*/
6925+
const loadContextHook = hydrateMessages
6926+
? (event: HydrateMessagesEvent<inferSchemaOut<TClientDataSchema>, TUIMessage>) =>
6927+
hydrateMessages(event)
6928+
: storageLoadContext
6929+
? (event: HydrateMessagesEvent<inferSchemaOut<TClientDataSchema>, TUIMessage>) =>
6930+
storageLoadContext<TUIMessage>(
6931+
{ chatId: event.chatId, clientData: event.clientData },
6932+
event
6933+
)
6934+
: undefined;
68936935
let transcriptShadow: TranscriptShadow = createTranscriptShadow([]);
68946936
let bootTranscriptState: unknown = null;
68956937
/**
@@ -7066,7 +7108,7 @@ function chatAgent<
70667108
let bootInCursor: number | undefined;
70677109
let bootInCursorResolved = false;
70687110

7069-
if (!hydrateMessages && couldHavePriorState) {
7111+
if (couldHavePriorState) {
70707112
// Single parent span for the whole boot read phase — snapshot
70717113
// read, session.out replay, session.in replay. Per-phase timing
70727114
// + result counts are attributes on the span.
@@ -7076,18 +7118,26 @@ function chatAgent<
70767118
// snapshot read
70777119
const snapStart = Date.now();
70787120
try {
7079-
const loaded = await transcriptStorage.load<TUIMessage>({
7080-
chatId: payload.chatId,
7081-
clientData: bootClientData,
7082-
});
7083-
transcriptShadow = createTranscriptShadow(loaded.messages);
7084-
bootTranscriptState = loaded.state;
7085-
persistedStateSet = loaded.state !== null && loaded.state !== undefined;
7086-
bootSnapshot = {
7087-
messages: loaded.messages,
7088-
lastOutEventId: loaded.cursors?.lastOutEventId,
7089-
lastInEventId: loaded.cursors?.lastInEventId,
7090-
};
7121+
const loaded = hydrateMessages
7122+
? undefined
7123+
: await transcriptStorage.load<TUIMessage>({
7124+
chatId: payload.chatId,
7125+
clientData: bootClientData,
7126+
});
7127+
if (loaded) {
7128+
transcriptShadow = createTranscriptShadow(
7129+
loaded.messages,
7130+
new Set(loaded.nonFinalIds ?? [])
7131+
);
7132+
bootTranscriptState = loaded.state;
7133+
transcriptState = loaded.state ?? null;
7134+
persistedStateSet = loaded.state !== null && loaded.state !== undefined;
7135+
bootSnapshot = {
7136+
messages: loaded.messages,
7137+
lastOutEventId: loaded.cursors?.lastOutEventId,
7138+
lastInEventId: loaded.cursors?.lastInEventId,
7139+
};
7140+
}
70917141
} catch (error) {
70927142
logger.warn("chat.agent: transcript load failed; continuing from the stream tail", {
70937143
error: error instanceof Error ? error.message : String(error),
@@ -7225,7 +7275,7 @@ function chatAgent<
72257275
});
72267276

72277277
// ── Recovery boot + chain reconstruction ────────────────────────
7228-
if (!hydrateMessages) {
7278+
{
72297279
const settledMessages = mergeByIdReplaceWins<TUIMessage>(
72307280
(bootSnapshot?.messages as TUIMessage[]) ?? [],
72317281
replayedSettled
@@ -7394,6 +7444,7 @@ function chatAgent<
73947444
// and it's safe because the route handler isn't subject to the
73957445
// `/in/append` 512 KiB cap.
73967446
if (
7447+
!hydrateMessages &&
73977448
accumulatedUIMessages.length === 0 &&
73987449
payload.trigger === "handover-prepare" &&
73997450
Array.isArray(payload.headStartMessages) &&
@@ -8080,11 +8131,11 @@ function chatAgent<
80808131
: currentWirePayload.action;
80818132

80828133
// Hydrate messages from backend if configured
8083-
if (hydrateMessages) {
8134+
if (loadContextHook) {
80848135
const hydrated = await tracer.startActiveSpan(
80858136
"hydrateMessages()",
80868137
async () => {
8087-
return hydrateMessages({
8138+
return loadContextHook({
80888139
chatId: currentWirePayload.chatId,
80898140
turn,
80908141
trigger: "action",
@@ -8172,7 +8223,7 @@ function chatAgent<
81728223
// incoming messages instead (gated on the pending handover).
81738224
if (
81748225
turn === 0 &&
8175-
hydrateMessages &&
8226+
loadContextHook &&
81768227
cleanedUIMessages.length === 0 &&
81778228
(locals.get(chatHandoverPartialKey)?.length ?? 0) > 0 &&
81788229
Array.isArray(payload.headStartMessages) &&
@@ -8209,7 +8260,7 @@ function chatAgent<
82098260
)) as TUIMessage[];
82108261
}
82118262

8212-
if (hydrateMessages) {
8263+
if (loadContextHook) {
82138264
// Snapshot the ids the accumulator knew BEFORE this
82148265
// turn ran — used below to decide whether an
82158266
// incoming wire message is genuinely new or just a
@@ -8232,7 +8283,7 @@ function chatAgent<
82328283
const hydrated = await tracer.startActiveSpan(
82338284
"hydrateMessages()",
82348285
async () => {
8235-
return hydrateMessages({
8286+
return loadContextHook({
82368287
chatId: currentWirePayload.chatId,
82378288
turn,
82388289
trigger: currentWirePayload.trigger as

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,18 +84,42 @@ type TranscriptLoadResult<TUIMessage extends UIMessage = UIMessage> = {
8484
nonFinalIds?: string[];
8585
};
8686

87+
/** What `loadContext` receives on every turn and action. */
88+
type LoadContextEvent<TClientData = unknown, TUIMessage extends UIMessage = UIMessage> = {
89+
chatId: string;
90+
/** The turn number (0-indexed). */
91+
turn: number;
92+
trigger: "submit-message" | "regenerate-message" | "action";
93+
/** The messages the frontend sent for this turn. Empty for actions. */
94+
incomingMessages: TUIMessage[];
95+
/** The runtime's transcript before this turn, including any tail it recovered. */
96+
previousMessages: TUIMessage[];
97+
clientData?: TClientData;
98+
continuation: boolean;
99+
previousRunId?: string;
100+
};
101+
87102
/**
88103
* A persistence adapter for a `chat.agent` transcript. The runtime calls
89104
* `load` once at a continuation boot and `save` after every change to the
90105
* conversation. Both are best-effort from the runtime's point of view: an
91106
* error is logged and the turn continues.
107+
*
108+
* `loadContext` is optional. Its presence declares that the application
109+
* owns the model's context: the runtime calls it on every turn and action
110+
* and uses what it returns as the conversation, instead of the transcript
111+
* it accumulated. Tail recovery still runs and `save` is still called.
92112
*/
93113
export type TranscriptStorage<TClientData = unknown> = {
94114
load<TUIMessage extends UIMessage = UIMessage>(
95115
scope: TranscriptScope<TClientData>,
96116
opts?: TranscriptLoadOptions
97117
): Promise<TranscriptLoadResult<TUIMessage>>;
98118
save(ctx: TranscriptStorageContext<TClientData>, changeset: TranscriptChangeset): Promise<void>;
119+
loadContext?<TUIMessage extends UIMessage = UIMessage>(
120+
scope: TranscriptScope<TClientData>,
121+
event: LoadContextEvent<TClientData, TUIMessage>
122+
): Promise<TUIMessage[]> | TUIMessage[];
99123
};
100124

101125
/** An in-memory transcript: ordered entries plus the opaque state record. */

0 commit comments

Comments
 (0)