Skip to content

Commit 8292d10

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 2d6f77d commit 8292d10

3 files changed

Lines changed: 341 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
@@ -5191,6 +5191,18 @@ function isUIMessageStreamable(value: unknown): value is UIMessageStreamable {
51915191
);
51925192
}
51935193

5194+
const warnedHydrateMessagesDeprecated = new Set<string>();
5195+
function warnHydrateMessagesDeprecatedOnce(agentId: string) {
5196+
if (warnedHydrateMessagesDeprecated.has(agentId)) return;
5197+
warnedHydrateMessagesDeprecated.add(agentId);
5198+
console.warn(
5199+
`[chat.agent] \`hydrateMessages\` on "${agentId}" is deprecated. Give the agent a transcript ` +
5200+
"storage instead: `save` receives every change to the conversation and `loadContext` " +
5201+
"lets the application own the model's context, with crash recovery and durable " +
5202+
"compaction that `hydrateMessages` never had."
5203+
);
5204+
}
5205+
51945206
let warnedMissingOnAction = false;
51955207
function warnMissingOnActionOnce() {
51965208
if (warnedMissingOnAction) return;
@@ -5401,8 +5413,9 @@ export type RecoveryPendingToolCall = {
54015413
* `chat.endRun()` with no buffered user messages, fresh chat, OOM retry
54025414
* after a successful turn-complete with no in-flight tail).
54035415
*
5404-
* Does NOT fire when `hydrateMessages` is registered (the customer owns
5405-
* persistence; recovery decisions live in their own DB query).
5416+
* Fires regardless of who owns the model's context. With `hydrateMessages`
5417+
* or a storage `loadContext`, the recovered tail reaches that hook in
5418+
* `previousMessages` on the next turn.
54065419
*/
54075420
export type RecoveryBootEvent<TUIM extends UIMessage = UIMessage> = {
54085421
/** Task run context — same as `task({ run })` second-argument `ctx`. */
@@ -5472,8 +5485,9 @@ export type RecoveryBootResult<TUIM extends UIMessage = UIMessage> = {
54725485
* context, mutate its tool parts to inject synthesized results,
54735486
* collapse history, etc.
54745487
*
5475-
* Ignored when `hydrateMessages` is registered (the hydrate hook
5476-
* runs per-turn and overwrites the chain).
5488+
* With `hydrateMessages` or a storage `loadContext`, this chain is what
5489+
* the hook receives as `previousMessages` on the next turn; the hook's
5490+
* return value is the chain the model sees.
54775491
*/
54785492
chain?: TUIM[];
54795493
/**
@@ -6058,9 +6072,9 @@ export type ChatAgentOptions<
60586072
* continuation after `chat.endRun()` with no buffered user, a fresh
60596073
* chat, or an OOM retry on top of a complete snapshot.
60606074
*
6061-
* Does NOT fire when `hydrateMessages` is registered — that hook owns
6062-
* the per-turn chain and overlapping recovery decisions belong in the
6063-
* customer's DB.
6075+
* Fires regardless of who owns the model's context; a `hydrateMessages`
6076+
* hook or a storage `loadContext` receives the recovered tail in
6077+
* `previousMessages` on the next turn.
60646078
*
60656079
* Defaults (returned when the hook is omitted or returns no field):
60666080
* - With two or more in-flight users, the partial and the user it
@@ -6832,6 +6846,18 @@ function chatAgent<
68326846
...restOptions
68336847
} = options;
68346848

6849+
if (hydrateMessages) {
6850+
const storageAtDefinition = transcriptStorageOverride ?? defaultStorage;
6851+
if (typeof storageAtDefinition.loadContext === "function") {
6852+
throw new Error(
6853+
`chat.agent: "${options.id}" sets \`hydrateMessages\` and uses a transcript storage with ` +
6854+
"`loadContext`. Both would own the model's context; keep one. `hydrateMessages` is " +
6855+
"deprecated, so prefer `loadContext` on the storage."
6856+
);
6857+
}
6858+
warnHydrateMessagesDeprecatedOnce(options.id);
6859+
}
6860+
68356861
const parseClientData = clientDataSchema ? getSchemaParseFn(clientDataSchema) : undefined;
68366862
const parseAction = actionSchema ? getSchemaParseFn(actionSchema) : undefined;
68376863

@@ -6999,6 +7025,22 @@ function chatAgent<
69997025
// swallow errors internally; the agent stays available either way.
70007026
const sessionIdForSnapshot = payload.sessionId ?? payload.chatId;
70017027
const transcriptStorage = transcriptStorageOverride ?? defaultStorage;
7028+
const storageLoadContext = transcriptStorage.loadContext?.bind(transcriptStorage);
7029+
/**
7030+
* Who supplies the model's context each turn: the deprecated
7031+
* `hydrateMessages` hook, the storage's `loadContext`, or (undefined)
7032+
* the runtime's own transcript.
7033+
*/
7034+
const loadContextHook = hydrateMessages
7035+
? (event: HydrateMessagesEvent<inferSchemaOut<TClientDataSchema>, TUIMessage>) =>
7036+
hydrateMessages(event)
7037+
: storageLoadContext
7038+
? (event: HydrateMessagesEvent<inferSchemaOut<TClientDataSchema>, TUIMessage>) =>
7039+
storageLoadContext<TUIMessage>(
7040+
{ chatId: event.chatId, clientData: event.clientData },
7041+
event
7042+
)
7043+
: undefined;
70027044
let transcriptShadow: TranscriptShadow = createTranscriptShadow([]);
70037045
let bootTranscriptState: unknown = null;
70047046
/**
@@ -7181,7 +7223,7 @@ function chatAgent<
71817223
let bootInCursor: number | undefined;
71827224
let bootInCursorResolved = false;
71837225

7184-
if (!hydrateMessages && couldHavePriorState) {
7226+
if (couldHavePriorState) {
71857227
// Single parent span for the whole boot read phase — snapshot
71867228
// read, session.out replay, session.in replay. Per-phase timing
71877229
// + result counts are attributes on the span.
@@ -7191,18 +7233,26 @@ function chatAgent<
71917233
// snapshot read
71927234
const snapStart = Date.now();
71937235
try {
7194-
const loaded = await transcriptStorage.load<TUIMessage>({
7195-
chatId: payload.chatId,
7196-
clientData: bootClientData,
7197-
});
7198-
transcriptShadow = createTranscriptShadow(loaded.messages);
7199-
bootTranscriptState = loaded.state;
7200-
persistedStateSet = loaded.state !== null && loaded.state !== undefined;
7201-
bootSnapshot = {
7202-
messages: loaded.messages,
7203-
lastOutEventId: loaded.cursors?.lastOutEventId,
7204-
lastInEventId: loaded.cursors?.lastInEventId,
7205-
};
7236+
const loaded = hydrateMessages
7237+
? undefined
7238+
: await transcriptStorage.load<TUIMessage>({
7239+
chatId: payload.chatId,
7240+
clientData: bootClientData,
7241+
});
7242+
if (loaded) {
7243+
transcriptShadow = createTranscriptShadow(
7244+
loaded.messages,
7245+
new Set(loaded.nonFinalIds ?? [])
7246+
);
7247+
bootTranscriptState = loaded.state;
7248+
transcriptState = loaded.state ?? null;
7249+
persistedStateSet = loaded.state !== null && loaded.state !== undefined;
7250+
bootSnapshot = {
7251+
messages: loaded.messages,
7252+
lastOutEventId: loaded.cursors?.lastOutEventId,
7253+
lastInEventId: loaded.cursors?.lastInEventId,
7254+
};
7255+
}
72067256
} catch (error) {
72077257
logger.warn("chat.agent: transcript load failed; continuing from the stream tail", {
72087258
error: error instanceof Error ? error.message : String(error),
@@ -7346,7 +7396,7 @@ function chatAgent<
73467396
});
73477397

73487398
// ── Recovery boot + chain reconstruction ────────────────────────
7349-
if (!hydrateMessages) {
7399+
{
73507400
const settledMessages = mergeByIdReplaceWins<TUIMessage>(
73517401
(bootSnapshot?.messages as TUIMessage[]) ?? [],
73527402
replayedSettled
@@ -7504,6 +7554,7 @@ function chatAgent<
75047554
// and it's safe because the route handler isn't subject to the
75057555
// `/in/append` 512 KiB cap.
75067556
if (
7557+
!loadContextHook &&
75077558
accumulatedUIMessages.length === 0 &&
75087559
payload.trigger === "handover-prepare" &&
75097560
Array.isArray(payload.headStartMessages) &&
@@ -8201,11 +8252,11 @@ function chatAgent<
82018252
: currentWirePayload.action;
82028253

82038254
// Hydrate messages from backend if configured
8204-
if (hydrateMessages) {
8255+
if (loadContextHook) {
82058256
const hydrated = await tracer.startActiveSpan(
82068257
"hydrateMessages()",
82078258
async () => {
8208-
return hydrateMessages({
8259+
return loadContextHook({
82098260
chatId: currentWirePayload.chatId,
82108261
turn,
82118262
trigger: "action",
@@ -8293,7 +8344,7 @@ function chatAgent<
82938344
// incoming messages instead (gated on the pending handover).
82948345
if (
82958346
turn === 0 &&
8296-
hydrateMessages &&
8347+
loadContextHook &&
82978348
cleanedUIMessages.length === 0 &&
82988349
(locals.get(chatHandoverPartialKey)?.length ?? 0) > 0 &&
82998350
Array.isArray(payload.headStartMessages) &&
@@ -8330,7 +8381,7 @@ function chatAgent<
83308381
)) as TUIMessage[];
83318382
}
83328383

8333-
if (hydrateMessages) {
8384+
if (loadContextHook) {
83348385
// Snapshot the ids the accumulator knew BEFORE this
83358386
// turn ran — used below to decide whether an
83368387
// incoming wire message is genuinely new or just a
@@ -8353,7 +8404,7 @@ function chatAgent<
83538404
const hydrated = await tracer.startActiveSpan(
83548405
"hydrateMessages()",
83558406
async () => {
8356-
return hydrateMessages({
8407+
return loadContextHook({
83578408
chatId: currentWirePayload.chatId,
83588409
turn,
83598410
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)