Skip to content

Commit 21bc51f

Browse files
authored
feat(sdk): persist compaction and injected context through the transcript storage state (#4894)
## Summary Makes a compaction summary and `chat.inject` context survive a continuation run, for every storage including the default. Until now the model lane after a compaction lived only in the running worker. When the next run booted it rebuilt the lane from the transcript, so every continuation re-read the whole conversation and summarised it again. The same applied to conversational messages added with `chat.inject`: they lived for the worker's life and vanished on a continuation. ## Design The runtime records what it cannot rebuild from the transcript in the storage's `state` slot: after a compaction, the compacted model lane together with the transcript id it covers and a fingerprint of that prefix; for injections, the messages anchored to the transcript message they followed. At boot the compacted lane is used when the covered prefix is unchanged, otherwise the lane is converted from the transcript as before, and injections are re-inserted after their anchors. A rollback or edit that reconverts the lane clears the state in the same changeset as the `truncateAfter`, so a storage never holds a summary for a transcript it no longer matches. A mid-turn steering message reaches the storage as a `put` in that turn's changeset. An in-memory storage that logs the changesets it receives, and a test-only override for the storage the runtime persists through, let the tests assert the exact changesets for a turn, a steer, a compaction, a rollback and an injection.
1 parent fd3b1f6 commit 21bc51f

3 files changed

Lines changed: 888 additions & 20 deletions

File tree

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

Lines changed: 144 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,29 @@ import {
8080
createTranscriptShadow,
8181
defaultStorage,
8282
diffTranscript,
83+
parseTranscriptRuntimeState,
84+
prefixFingerprint,
85+
restoreModelLane,
86+
type TranscriptChange,
8387
type TranscriptChangeReason,
88+
type TranscriptRuntimeState,
8489
type TranscriptShadow,
90+
type TranscriptStorage,
8591
type TranscriptStorageContext,
8692
} from "./transcriptStorage.js";
93+
94+
let transcriptStorageOverride: TranscriptStorage<unknown> | undefined;
95+
96+
/**
97+
* Test-only override for the storage `chat.agent` persists through, so a
98+
* test can capture the exact changesets the runtime produces.
99+
* @internal
100+
*/
101+
export function __setTranscriptStorageForTests(
102+
storage: TranscriptStorage<unknown> | undefined
103+
): void {
104+
transcriptStorageOverride = storage;
105+
}
87106
import {
88107
type ChatInputChunk,
89108
type ChatTaskWirePayload,
@@ -2542,6 +2561,13 @@ function spliceHandoverPartial(
25422561
* @internal
25432562
*/
25442563
const chatBackgroundQueueKey = locals.create<ModelMessage[]>("chat.backgroundQueue");
2564+
/**
2565+
* Background injections a step-boundary drain handed to the model this turn,
2566+
* with the transcript message they followed. Reconciled into the model lane
2567+
* and the persisted injections once the turn's response is in.
2568+
*/
2569+
const chatPendingBackgroundKey =
2570+
locals.create<{ afterId: string; messages: ModelMessage[] }[]>("chat.pendingBackground");
25452571

25462572
/**
25472573
* System-role context injected mid-conversation, held for the instructions lane.
@@ -5022,6 +5048,13 @@ function toStreamTextOptions(options?: ToStreamTextOptionsOptions): Record<strin
50225048
if (bgQueue && bgQueue.length > 0) {
50235049
const injected = bgQueue.splice(0); // drain
50245050
resultMessages = [...(resultMessages ?? messages), ...injected];
5051+
const pendingBackground = locals.get(chatPendingBackgroundKey) ?? [];
5052+
pendingBackground.push({
5053+
afterId:
5054+
(locals.get(chatCurrentUIMessagesKey) as UIMessage[] | undefined)?.at(-1)?.id ?? "",
5055+
messages: injected,
5056+
});
5057+
locals.set(chatPendingBackgroundKey, pendingBackground);
50255058
}
50265059

50275060
return resultMessages ? { messages: resultMessages } : undefined;
@@ -6912,6 +6945,23 @@ function chatAgent<
69126945
// durable snapshot + `session.out` replay (or `hydrateMessages` if
69136946
// registered) — the wire is delta-only now, no longer a seed.
69146947
let accumulatedMessages: ModelMessage[] = [];
6948+
/**
6949+
* Give the model accumulator the background injections a step-boundary
6950+
* drain handed to the model this turn, and record them for persistence.
6951+
* Returns how many model messages were appended.
6952+
*/
6953+
const reconcilePendingBackground = (): number => {
6954+
const pending = locals.get(chatPendingBackgroundKey);
6955+
if (!pending || pending.length === 0) return 0;
6956+
locals.set(chatPendingBackgroundKey, []);
6957+
let appended = 0;
6958+
for (const entry of pending) {
6959+
accumulatedMessages.push(...entry.messages);
6960+
laneInjections.push(entry);
6961+
appended += entry.messages.length;
6962+
}
6963+
return appended;
6964+
};
69156965
/**
69166966
* Give the model accumulator the steering messages a drain consumed,
69176967
* in the form the model actually received. Appended, never reconverted
@@ -6951,8 +7001,18 @@ function chatAgent<
69517001
// collectively cost ~600ms on every first-message TTFC. Both reads
69527002
// swallow errors internally; the agent stays available either way.
69537003
const sessionIdForSnapshot = payload.sessionId ?? payload.chatId;
6954-
const transcriptStorage = defaultStorage;
7004+
const transcriptStorage = transcriptStorageOverride ?? defaultStorage;
69557005
let transcriptShadow: TranscriptShadow = createTranscriptShadow([]);
7006+
let bootTranscriptState: unknown = null;
7007+
/**
7008+
* True while the model lane holds a compaction summary, so it cannot be
7009+
* rebuilt from the transcript and has to be persisted as state. Reset
7010+
* wherever the lane is reconverted from the UI lane.
7011+
*/
7012+
let laneCompacted = false;
7013+
/** Conversational `chat.inject` messages in the lane, anchored to the transcript. */
7014+
let laneInjections: NonNullable<TranscriptRuntimeState["injections"]> = [];
7015+
let persistedStateSet = false;
69567016
let bootSnapshot:
69577017
| { messages: TUIMessage[]; lastOutEventId?: string; lastInEventId?: string }
69587018
| undefined;
@@ -7002,6 +7062,30 @@ function chatAgent<
70027062
const { changes, shadow } = diffTranscript(transcriptShadow, opts.messages, {
70037063
nonFinalIds: opts.nonFinalIds,
70047064
});
7065+
const throughId = opts.messages.at(-1)?.id ?? "";
7066+
const queued = locals.get(chatBackgroundQueueKey) ?? [];
7067+
const runtimeState: TranscriptRuntimeState | null =
7068+
laneCompacted || laneInjections.length > 0 || queued.length > 0
7069+
? {
7070+
v: 1,
7071+
...(laneCompacted
7072+
? {
7073+
compaction: {
7074+
modelMessages: accumulatedMessages,
7075+
throughId,
7076+
fingerprint: prefixFingerprint(shadow, throughId),
7077+
},
7078+
}
7079+
: laneInjections.length > 0
7080+
? { injections: laneInjections }
7081+
: {}),
7082+
...(queued.length > 0 ? { queued: [...queued] } : {}),
7083+
}
7084+
: null;
7085+
if (runtimeState !== null || persistedStateSet) {
7086+
changes.push({ op: "state", value: runtimeState } satisfies TranscriptChange);
7087+
}
7088+
transcriptState = runtimeState;
70057089
const inCursor = chatInputRouter().resumeFloor();
70067090
await transcriptStorage.save(
70077091
{
@@ -7030,6 +7114,7 @@ function chatAgent<
70307114
}
70317115
);
70327116
transcriptShadow = shadow;
7117+
persistedStateSet = runtimeState !== null;
70337118
};
70347119

70357120
/**
@@ -7114,6 +7199,8 @@ function chatAgent<
71147199
clientData: bootClientData,
71157200
});
71167201
transcriptShadow = createTranscriptShadow(loaded.messages);
7202+
bootTranscriptState = loaded.state;
7203+
persistedStateSet = loaded.state !== null && loaded.state !== undefined;
71177204
bootSnapshot = {
71187205
messages: loaded.messages,
71197206
lastOutEventId: loaded.cursors?.lastOutEventId,
@@ -7453,7 +7540,21 @@ function chatAgent<
74537540
}
74547541
}
74557542
try {
7456-
accumulatedMessages = await toModelMessages(accumulatedUIMessages);
7543+
const bootRuntimeState = parseTranscriptRuntimeState(bootTranscriptState);
7544+
const restored = await restoreModelLane(
7545+
accumulatedUIMessages,
7546+
bootRuntimeState,
7547+
(messages) => toModelMessages(messages)
7548+
);
7549+
accumulatedMessages = restored.messages;
7550+
laneCompacted = restored.compacted;
7551+
laneInjections = restored.injections;
7552+
if (bootRuntimeState?.queued && bootRuntimeState.queued.length > 0) {
7553+
locals.set(chatBackgroundQueueKey, [
7554+
...(locals.get(chatBackgroundQueueKey) ?? []),
7555+
...bootRuntimeState.queued,
7556+
]);
7557+
}
74577558
} catch (error) {
74587559
logger.warn("chat.agent: toModelMessages failed at boot; starting empty", {
74597560
error: error instanceof Error ? error.message : String(error),
@@ -7982,6 +8083,7 @@ function chatAgent<
79828083
locals.set(chatDeferKey, new Set());
79838084
locals.set(chatCompactionStateKey, undefined);
79848085
locals.set(chatSteeringQueueKey, []);
8086+
locals.set(chatPendingBackgroundKey, []);
79858087
locals.set(chatResponsePartsKey, []);
79868088
// NOTE: chatBackgroundQueueKey is NOT reset here — messages injected
79878089
// by deferred work from the previous turn's onTurnComplete need to
@@ -8128,6 +8230,8 @@ function chatAgent<
81288230
);
81298231
accumulatedUIMessages = [...hydrated] as TUIMessage[];
81308232
accumulatedMessages = await toModelMessages(hydrated);
8233+
laneCompacted = false;
8234+
laneInjections = [];
81318235
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
81328236
}
81338237

@@ -8165,6 +8269,8 @@ function chatAgent<
81658269
locals.set(chatOverrideMessagesKey, undefined);
81668270
accumulatedUIMessages = [...actionOverride] as TUIMessage[];
81678271
accumulatedMessages = await toModelMessages(actionOverride);
8272+
laneCompacted = false;
8273+
laneInjections = [];
81688274
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
81698275

81708276
actionChangedHistory = true;
@@ -8297,6 +8403,8 @@ function chatAgent<
82978403

82988404
accumulatedUIMessages = merged;
82998405
accumulatedMessages = await toModelMessages(merged);
8406+
laneCompacted = false;
8407+
laneInjections = [];
83008408
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
83018409

83028410
// Track new messages for onTurnComplete.newUIMessages.
@@ -8346,6 +8454,8 @@ function chatAgent<
83468454
accumulatedUIMessages.pop();
83478455
}
83488456
accumulatedMessages = await toModelMessages(accumulatedUIMessages);
8457+
laneCompacted = false;
8458+
laneInjections = [];
83498459
} else if (cleanedUIMessages.length > 0) {
83508460
// Submit-message (and the special-cased
83518461
// handover-prepare → submit-message rewrite earlier in
@@ -8399,6 +8509,8 @@ function chatAgent<
83998509
"chat.agent: replaced message not found at the model lane tail; reconverting the lane"
84008510
);
84018511
accumulatedMessages = await toModelMessages(accumulatedUIMessages);
8512+
laneCompacted = false;
8513+
laneInjections = [];
84028514
}
84038515
} else {
84048516
const incomingModelMessages = await toModelMessages(cleanedUIMessages);
@@ -8608,6 +8720,8 @@ function chatAgent<
86088720
locals.set(chatOverrideMessagesKey, undefined);
86098721
accumulatedUIMessages = [...turnStartOverride] as TUIMessage[];
86108722
accumulatedMessages = await toModelMessages(turnStartOverride);
8723+
laneCompacted = false;
8724+
laneInjections = [];
86118725
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
86128726
}
86138727
},
@@ -8676,7 +8790,12 @@ function chatAgent<
86768790
const lastAccumulated = accumulatedMessages[accumulatedMessages.length - 1];
86778791
const bgQueue = locals.get(chatBackgroundQueueKey);
86788792
if (bgQueue && bgQueue.length > 0 && lastAccumulated?.role !== "tool") {
8679-
accumulatedMessages.push(...bgQueue.splice(0));
8793+
const injected = bgQueue.splice(0);
8794+
accumulatedMessages.push(...injected);
8795+
laneInjections.push({
8796+
afterId: accumulatedUIMessages.at(-1)?.id ?? "",
8797+
messages: injected,
8798+
});
86808799
}
86818800

86828801
if (isHeadStartFinalTurn) {
@@ -8869,6 +8988,8 @@ function chatAgent<
88698988
accumulatedMessages = await toModelMessages(
88708989
runOverride.filter((m) => !pendingIds.has(m.id))
88718990
);
8991+
laneCompacted = false;
8992+
laneInjections = [];
88728993
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
88738994
}
88748995

@@ -8894,6 +9015,8 @@ function chatAgent<
88949015
accumulatedMessages = taskCompactionConfig?.compactModelMessages
88959016
? await taskCompactionConfig.compactModelMessages(compactEvent)
88969017
: modelOnlyOverride;
9018+
laneCompacted = true;
9019+
laneInjections = [];
88979020

88989021
// Apply UI messages: callback or default (preserve all)
88999022
if (taskCompactionConfig?.compactUIMessages) {
@@ -8912,9 +9035,10 @@ function chatAgent<
89129035
// before the response is appended so the order stays
89139036
// steer-then-answer. Outside the `capturedResponseMessage`
89149037
// branches below, so a turn that captured no response is covered.
8915-
const steerTailThisTurn = reconcilePendingSteer({
8916-
turnNew: turnNewModelMessages,
8917-
}).reduce((n, e) => n + e.model.length, 0);
9038+
const steerTailThisTurn =
9039+
reconcilePendingSteer({
9040+
turnNew: turnNewModelMessages,
9041+
}).reduce((n, e) => n + e.model.length, 0) + reconcilePendingBackground();
89189042

89199043
// Append the assistant's response (partial or complete) to the accumulator.
89209044
// The onFinish callback fires even on abort/stop, so partial responses
@@ -8986,6 +9110,8 @@ function chatAgent<
89869110
"chat.agent: replaced response not found at the model lane tail; reconverting the lane"
89879111
);
89889112
accumulatedMessages = await toModelMessages(accumulatedUIMessages);
9113+
laneCompacted = false;
9114+
laneInjections = [];
89899115
}
89909116
} else {
89919117
accumulatedMessages.push(...responseModelMessages);
@@ -9105,6 +9231,9 @@ function chatAgent<
91059231
},
91069232
];
91079233

9234+
laneCompacted = true;
9235+
laneInjections = [];
9236+
91089237
// UI messages: callback or default (preserve all)
91099238
if (outerCompaction.compactUIMessages) {
91109239
accumulatedUIMessages = (await outerCompaction.compactUIMessages(
@@ -9209,6 +9338,8 @@ function chatAgent<
92099338
locals.set(chatOverrideMessagesKey, undefined);
92109339
accumulatedUIMessages = [...override] as TUIMessage[];
92119340
accumulatedMessages = await toModelMessages(override);
9341+
laneCompacted = false;
9342+
laneInjections = [];
92129343
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
92139344
// Update event so onTurnComplete sees compacted messages
92149345
turnCompleteEvent.messages = accumulatedMessages;
@@ -9268,6 +9399,8 @@ function chatAgent<
92689399
locals.set(chatOverrideMessagesKey, undefined);
92699400
accumulatedUIMessages = [...turnCompleteOverride] as TUIMessage[];
92709401
accumulatedMessages = await toModelMessages(turnCompleteOverride);
9402+
laneCompacted = false;
9403+
laneInjections = [];
92719404
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
92729405
}
92739406
},
@@ -9602,6 +9735,7 @@ function chatAgent<
96029735
let erroredNewModelMessages: ModelMessage[] = [];
96039736

96049737
const reconciledSteer = reconcilePendingSteer();
9738+
const backgroundTailThisTurn = reconcilePendingBackground();
96059739

96069740
if (!responseCommitted) {
96079741
try {
@@ -9637,13 +9771,16 @@ function chatAgent<
96379771
accumulatedMessages,
96389772
erroredUIMessages[partialIdx]!,
96399773
partialResponse!,
9640-
reconciledSteer.reduce((n, e) => n + e.model.length, 0)
9774+
reconciledSteer.reduce((n, e) => n + e.model.length, 0) +
9775+
backgroundTailThisTurn
96419776
);
96429777
if (!ok) {
96439778
logger.warn(
96449779
"chat.agent: replaced partial not found at the model lane tail; reconverting the lane"
96459780
);
96469781
accumulatedMessages = await toModelMessages(erroredUIMessagesWithPartial);
9782+
laneCompacted = false;
9783+
laneInjections = [];
96479784
}
96489785
}
96499786
accumulatedUIMessages = erroredUIMessagesWithPartial;

0 commit comments

Comments
 (0)