Skip to content

Commit c5e1f5d

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 b986d19 commit c5e1f5d

3 files changed

Lines changed: 280 additions & 26 deletions

File tree

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

Lines changed: 73 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
/**
@@ -7055,7 +7097,7 @@ function chatAgent<
70557097
let bootInCursor: number | undefined;
70567098
let bootInCursorResolved = false;
70577099

7058-
if (!hydrateMessages && couldHavePriorState) {
7100+
if (couldHavePriorState) {
70597101
// Single parent span for the whole boot read phase — snapshot
70607102
// read, session.out replay, session.in replay. Per-phase timing
70617103
// + result counts are attributes on the span.
@@ -7065,18 +7107,22 @@ function chatAgent<
70657107
// snapshot read
70667108
const snapStart = Date.now();
70677109
try {
7068-
const loaded = await transcriptStorage.load<TUIMessage>({
7069-
chatId: payload.chatId,
7070-
clientData: bootClientData,
7071-
});
7072-
transcriptShadow = createTranscriptShadow(loaded.messages);
7073-
bootTranscriptState = loaded.state;
7074-
persistedStateSet = loaded.state !== null && loaded.state !== undefined;
7075-
bootSnapshot = {
7076-
messages: loaded.messages,
7077-
lastOutEventId: loaded.cursors?.lastOutEventId,
7078-
lastInEventId: loaded.cursors?.lastInEventId,
7079-
};
7110+
const loaded = hydrateMessages
7111+
? undefined
7112+
: await transcriptStorage.load<TUIMessage>({
7113+
chatId: payload.chatId,
7114+
clientData: bootClientData,
7115+
});
7116+
if (loaded) {
7117+
transcriptShadow = createTranscriptShadow(loaded.messages);
7118+
bootTranscriptState = loaded.state;
7119+
persistedStateSet = loaded.state !== null && loaded.state !== undefined;
7120+
bootSnapshot = {
7121+
messages: loaded.messages,
7122+
lastOutEventId: loaded.cursors?.lastOutEventId,
7123+
lastInEventId: loaded.cursors?.lastInEventId,
7124+
};
7125+
}
70807126
} catch (error) {
70817127
logger.warn("chat.agent: transcript load failed; continuing from the stream tail", {
70827128
error: error instanceof Error ? error.message : String(error),
@@ -7214,7 +7260,7 @@ function chatAgent<
72147260
});
72157261

72167262
// ── Recovery boot + chain reconstruction ────────────────────────
7217-
if (!hydrateMessages) {
7263+
{
72187264
const settledMessages = mergeByIdReplaceWins<TUIMessage>(
72197265
(bootSnapshot?.messages as TUIMessage[]) ?? [],
72207266
replayedSettled
@@ -7383,6 +7429,7 @@ function chatAgent<
73837429
// and it's safe because the route handler isn't subject to the
73847430
// `/in/append` 512 KiB cap.
73857431
if (
7432+
!hydrateMessages &&
73867433
accumulatedUIMessages.length === 0 &&
73877434
payload.trigger === "handover-prepare" &&
73887435
Array.isArray(payload.headStartMessages) &&
@@ -8069,11 +8116,11 @@ function chatAgent<
80698116
: currentWirePayload.action;
80708117

80718118
// Hydrate messages from backend if configured
8072-
if (hydrateMessages) {
8119+
if (loadContextHook) {
80738120
const hydrated = await tracer.startActiveSpan(
80748121
"hydrateMessages()",
80758122
async () => {
8076-
return hydrateMessages({
8123+
return loadContextHook({
80778124
chatId: currentWirePayload.chatId,
80788125
turn,
80798126
trigger: "action",
@@ -8161,7 +8208,7 @@ function chatAgent<
81618208
// incoming messages instead (gated on the pending handover).
81628209
if (
81638210
turn === 0 &&
8164-
hydrateMessages &&
8211+
loadContextHook &&
81658212
cleanedUIMessages.length === 0 &&
81668213
(locals.get(chatHandoverPartialKey)?.length ?? 0) > 0 &&
81678214
Array.isArray(payload.headStartMessages) &&
@@ -8198,7 +8245,7 @@ function chatAgent<
81988245
)) as TUIMessage[];
81998246
}
82008247

8201-
if (hydrateMessages) {
8248+
if (loadContextHook) {
82028249
// Snapshot the ids the accumulator knew BEFORE this
82038250
// turn ran — used below to decide whether an
82048251
// incoming wire message is genuinely new or just a
@@ -8221,7 +8268,7 @@ function chatAgent<
82218268
const hydrated = await tracer.startActiveSpan(
82228269
"hydrateMessages()",
82238270
async () => {
8224-
return hydrateMessages({
8271+
return loadContextHook({
82258272
chatId: currentWirePayload.chatId,
82268273
turn,
82278274
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
@@ -73,18 +73,42 @@ type TranscriptLoadResult<TUIMessage extends UIMessage = UIMessage> = {
7373
nextCursor?: string;
7474
};
7575

76+
/** What `loadContext` receives on every turn and action. */
77+
type LoadContextEvent<TClientData = unknown, TUIMessage extends UIMessage = UIMessage> = {
78+
chatId: string;
79+
/** The turn number (0-indexed). */
80+
turn: number;
81+
trigger: "submit-message" | "regenerate-message" | "action";
82+
/** The messages the frontend sent for this turn. Empty for actions. */
83+
incomingMessages: TUIMessage[];
84+
/** The runtime's transcript before this turn, including any tail it recovered. */
85+
previousMessages: TUIMessage[];
86+
clientData?: TClientData;
87+
continuation: boolean;
88+
previousRunId?: string;
89+
};
90+
7691
/**
7792
* A persistence adapter for a `chat.agent` transcript. The runtime calls
7893
* `load` once at a continuation boot and `save` after every change to the
7994
* conversation. Both are best-effort from the runtime's point of view: an
8095
* error is logged and the turn continues.
96+
*
97+
* `loadContext` is optional. Its presence declares that the application
98+
* owns the model's context: the runtime calls it on every turn and action
99+
* and uses what it returns as the conversation, instead of the transcript
100+
* it accumulated. Tail recovery still runs and `save` is still called.
81101
*/
82102
export type TranscriptStorage<TClientData = unknown> = {
83103
load<TUIMessage extends UIMessage = UIMessage>(
84104
scope: TranscriptScope<TClientData>,
85105
opts?: TranscriptLoadOptions
86106
): Promise<TranscriptLoadResult<TUIMessage>>;
87107
save(ctx: TranscriptStorageContext<TClientData>, changeset: TranscriptChangeset): Promise<void>;
108+
loadContext?<TUIMessage extends UIMessage = UIMessage>(
109+
scope: TranscriptScope<TClientData>,
110+
event: LoadContextEvent<TClientData, TUIMessage>
111+
): Promise<TUIMessage[]> | TUIMessage[];
88112
};
89113

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

0 commit comments

Comments
 (0)