Skip to content

Commit e31293c

Browse files
authored
feat: queue chat prompts and restore conversation checkpoints
feat: queue chat prompts and restore conversation checkpoints
2 parents 536a911 + a48b613 commit e31293c

23 files changed

Lines changed: 1588 additions & 54 deletions

apps/api/src/config-api.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
type JobRecord,
3939
type MetadataStore,
4040
type QueryHistoryRecord,
41+
type RunRecord,
4142
type RunEventRecord,
4243
type SessionRecord,
4344
type UserRecord
@@ -385,6 +386,12 @@ const handleSessionRequest = async (
385386
runId,
386387
events: context.metadataStore.runEvents.listByRun({ user_id: context.userId, run_id: runId })
387388
}));
389+
const checkpoints = runCheckpointDtos({
390+
context,
391+
messages,
392+
runEventGroups,
393+
runIds
394+
});
388395
const pendingInteractions = context.metadataStore.interactions.listPendingBySession({
389396
user_id: context.userId,
390397
session_id: sessionId
@@ -398,6 +405,7 @@ const handleSessionRequest = async (
398405
messages: messages.map(conversationMessageDto),
399406
...(latestSummary ? { summary: conversationSummaryDto(latestSummary) } : {}),
400407
runEventRefs: runEventGroups.map(({ runId, events }) => runEventRefDto(runId, events)),
408+
...(checkpoints.length > 0 ? { checkpoints } : {}),
401409
toolCalls: runEventGroups.flatMap(({ runId, events }) => toolCallPairDtos(runId, events)),
402410
pendingInteractions: pendingInteractions.map(pendingInteractionDto),
403411
restorableCustomEvents: runEventGroups.flatMap(({ runId, events }) =>
@@ -1676,6 +1684,56 @@ const runEventRefDto = (runId: string, events: RunEventRecord[]): Record<string,
16761684
...(events.at(-1) ? { lastSeq: events.at(-1)?.seq } : {})
16771685
});
16781686

1687+
const runCheckpointDtos = (input: {
1688+
context: Required<ConfigApiContext>;
1689+
messages: ConversationMessageRecord[];
1690+
runEventGroups: Array<{ events: RunEventRecord[]; runId: string }>;
1691+
runIds: string[];
1692+
}): Record<string, unknown>[] => {
1693+
const eventsByRun = new Map(input.runEventGroups.map((group) => [group.runId, group.events]));
1694+
return input.runIds.flatMap((runId) => {
1695+
const run = input.context.metadataStore.runs.find({
1696+
user_id: input.context.userId,
1697+
run_id: runId
1698+
});
1699+
if (!run) {
1700+
return [];
1701+
}
1702+
return [
1703+
runCheckpointDto({
1704+
events: eventsByRun.get(runId) ?? [],
1705+
messages: input.messages.filter((message) => message.run_id === runId),
1706+
run
1707+
})
1708+
];
1709+
});
1710+
};
1711+
1712+
const runCheckpointDto = (input: {
1713+
events: RunEventRecord[];
1714+
messages: ConversationMessageRecord[];
1715+
run: RunRecord;
1716+
}): Record<string, unknown> => {
1717+
const positions = input.messages.map((message) => message.position);
1718+
const firstEvent = input.events[0];
1719+
const lastEvent = input.events.at(-1);
1720+
return {
1721+
runId: input.run.id,
1722+
status: input.run.status,
1723+
...(positions.length > 0
1724+
? {
1725+
messageStartPosition: Math.min(...positions),
1726+
messageEndPosition: Math.max(...positions)
1727+
}
1728+
: {}),
1729+
...(firstEvent ? { firstEventSeq: firstEvent.seq } : {}),
1730+
...(lastEvent ? { lastEventSeq: lastEvent.seq } : {}),
1731+
startedAt: input.run.started_at,
1732+
...(input.run.finished_at ? { finishedAt: input.run.finished_at } : {}),
1733+
...(input.run.error_message ? { errorMessage: input.run.error_message } : {})
1734+
};
1735+
};
1736+
16791737
const RESTORABLE_CUSTOM_EVENT_NAMES = new Set([
16801738
"token_usage",
16811739
"token_usage.correlation",

apps/api/src/conversation-memory.ts

Lines changed: 181 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import {
44
type ConversationMessageRecord,
55
type ConversationMessageRepository,
66
type ConversationSummaryRecord,
7-
type ConversationSummaryRepository
7+
type ConversationSummaryRepository,
8+
type RunEventRecord,
9+
type RunEventRepository
810
} from "@datafoundry/metadata";
911
import { createHash } from "node:crypto";
1012

@@ -53,6 +55,7 @@ export type ConversationMemoryServiceInput = {
5355
memoryBridge?: ConversationMemoryBridge | undefined;
5456
policy?: ConversationMemoryWindowPolicy | undefined;
5557
repository: ConversationMessageRepository;
58+
runEvents?: RunEventRepository | undefined;
5659
sessionId: string;
5760
summarizer?: ConversationSummarizer | undefined;
5861
summaryRepository?: ConversationSummaryRepository | undefined;
@@ -83,6 +86,7 @@ type BuildConversationMessagesInput = {
8386
runId: string;
8487
runInput: RunAgentInput;
8588
summary?: ConversationSummaryRecord | undefined;
89+
toolCheckpoints?: Map<string, string> | undefined;
8690
historyLimit?: number;
8791
maxMessageChars?: number;
8892
};
@@ -143,6 +147,14 @@ export class ConversationMemoryService {
143147
session_id: this.input.sessionId
144148
});
145149
const effectiveHistory = summary ? history.filter((record) => record.position > summary.to_position) : history;
150+
const toolCheckpoints = this.input.runEvents
151+
? buildToolCheckpointsForHistory({
152+
history: effectiveHistory,
153+
maxChars: policy.maxMessageChars,
154+
runEvents: this.input.runEvents,
155+
userId: this.input.userId
156+
})
157+
: undefined;
146158
return buildConversationMemoryMessagesWithReport({
147159
compactMemorySource: this.input.compactMemorySource,
148160
currentUserText: input.currentUserText,
@@ -152,6 +164,7 @@ export class ConversationMemoryService {
152164
runId: input.runId,
153165
runInput: input.runInput,
154166
summary,
167+
toolCheckpoints,
155168
tokenCounter: this.tokenCounter
156169
});
157170
}
@@ -253,7 +266,7 @@ export const buildConversationMemoryMessagesWithReport = (
253266
const currentUserMessageId = lastUserMessage(input.runInput)?.id ?? `${input.runId}:user`;
254267
const messages: Message[] = selected.summary ? [summaryToMessage(selected.summary, policy.maxMessageChars)] : [];
255268
messages.push(
256-
...normalizeHistoryMessagePairs(selected.history, policy.maxMessageChars)
269+
...normalizeHistoryMessagePairs(selected.history, policy.maxMessageChars, input.toolCheckpoints)
257270
);
258271

259272
messages.push({
@@ -510,28 +523,188 @@ const ORPHANED_USER_MESSAGE_PLACEHOLDER =
510523

511524
const normalizeHistoryMessagePairs = (
512525
records: ConversationMessageRecord[],
513-
maxMessageChars: number
526+
maxMessageChars: number,
527+
toolCheckpoints?: Map<string, string> | undefined
514528
): Message[] => {
515529
const result: Message[] = [];
530+
const consumedCheckpointRunIds = new Set<string>();
531+
const consumeToolCheckpoint = (runId: string): string | undefined => {
532+
if (consumedCheckpointRunIds.has(runId)) {
533+
return undefined;
534+
}
535+
consumedCheckpointRunIds.add(runId);
536+
return toolCheckpoints?.get(runId);
537+
};
516538
for (let i = 0; i < records.length; i++) {
517539
const cur = records[i]!;
518540
const next = records[i + 1] as ConversationMessageRecord | undefined;
541+
const checkpoint = cur.role === "assistant" ? consumeToolCheckpoint(cur.run_id) : undefined;
519542
result.push({
520543
id: `memory:${cur.id}`,
521544
role: cur.role,
522-
content: boundText(cur.content_text, maxMessageChars)
545+
content: boundText(appendToolCheckpoint(cur.content_text, checkpoint), maxMessageChars)
523546
});
524547
if (cur.role === "user" && (!next || next.role === "user")) {
548+
const orphanedCheckpoint = consumeToolCheckpoint(cur.run_id);
525549
result.push({
526550
id: `memory:${cur.id}:error-placeholder`,
527551
role: "assistant",
528-
content: ORPHANED_USER_MESSAGE_PLACEHOLDER
552+
content: boundText(
553+
appendToolCheckpoint(ORPHANED_USER_MESSAGE_PLACEHOLDER, orphanedCheckpoint),
554+
maxMessageChars
555+
)
529556
});
530557
}
531558
}
532559
return result;
533560
};
534561

562+
const appendToolCheckpoint = (content: string, checkpoint: string | undefined): string =>
563+
checkpoint ? `${content}\n\n${checkpoint}` : content;
564+
565+
const buildToolCheckpointsForHistory = (input: {
566+
history: ConversationMessageRecord[];
567+
maxChars: number;
568+
runEvents: RunEventRepository;
569+
userId: string;
570+
}): Map<string, string> => {
571+
const runIds = [...new Set(input.history.map((record) => record.run_id))];
572+
const checkpoints = new Map<string, string>();
573+
for (const runId of runIds) {
574+
const events = input.runEvents.listByRun({ user_id: input.userId, run_id: runId });
575+
const checkpoint = toolCheckpointText(runId, events, input.maxChars);
576+
if (checkpoint) {
577+
checkpoints.set(runId, checkpoint);
578+
}
579+
}
580+
return checkpoints;
581+
};
582+
583+
type ToolCheckpointCall = {
584+
args?: unknown;
585+
result?: unknown;
586+
resultSeq?: number;
587+
status: "completed" | "failed";
588+
toolCallId: string;
589+
toolName?: string;
590+
};
591+
592+
const toolCheckpointText = (
593+
runId: string,
594+
events: RunEventRecord[],
595+
maxChars: number
596+
): string | undefined => {
597+
const calls = new Map<string, Partial<ToolCheckpointCall> & { toolCallId: string }>();
598+
for (const eventRecord of events) {
599+
const event = parseJsonObject(eventRecord.payload_json);
600+
const type = stringValue(event.type);
601+
const toolCallId = stringValue(event.toolCallId);
602+
if (!type || !toolCallId) {
603+
continue;
604+
}
605+
const existing = calls.get(toolCallId) ?? { toolCallId };
606+
const toolName = stringValue(event.toolCallName) ?? existing.toolName;
607+
if (type === EventType.TOOL_CALL_START || type === EventType.TOOL_CALL_END) {
608+
calls.set(toolCallId, {
609+
...existing,
610+
...(toolName ? { toolName } : {}),
611+
...(existing.args !== undefined ? { args: existing.args } : {}),
612+
...(existing.args === undefined && toolCallArgs(event) !== undefined ? { args: toolCallArgs(event) } : {})
613+
});
614+
continue;
615+
}
616+
if (type === EventType.TOOL_CALL_RESULT) {
617+
calls.set(toolCallId, {
618+
...existing,
619+
...(toolName ? { toolName } : {}),
620+
result: event.content,
621+
resultSeq: eventRecord.seq,
622+
status: isToolResultError(event.content) ? "failed" : "completed"
623+
});
624+
}
625+
}
626+
627+
const completed = [...calls.values()]
628+
.filter((call): call is ToolCheckpointCall => call.result !== undefined && call.status !== undefined)
629+
.sort((a, b) => (a.resultSeq ?? 0) - (b.resultSeq ?? 0));
630+
if (completed.length === 0) {
631+
return undefined;
632+
}
633+
634+
const maxPayloadChars = Math.max(500, Math.floor(maxChars / Math.max(completed.length, 1)) - 400);
635+
const blocks = completed.map((call) => [
636+
`<tool_checkpoint run_id="${escapeAttribute(runId)}" tool_call_id="${escapeAttribute(call.toolCallId)}"`
637+
+ `${call.toolName ? ` tool_name="${escapeAttribute(call.toolName)}"` : ""}`
638+
+ ` status="${call.status}">`,
639+
"Completed tool observation from a previous run. Reuse this result when it answers the current task; repeat the tool only if the result is stale or insufficient.",
640+
call.args !== undefined ? `args: ${boundedSerializedValue(call.args, Math.min(1200, maxPayloadChars))}` : undefined,
641+
`result: ${boundedSerializedValue(call.result, maxPayloadChars)}`,
642+
"</tool_checkpoint>"
643+
].filter((line): line is string => line !== undefined).join("\n"));
644+
645+
return boundText(blocks.join("\n\n"), maxChars);
646+
};
647+
648+
const toolCallArgs = (event: Record<string, unknown>): unknown => {
649+
if (event.args !== undefined) {
650+
return event.args;
651+
}
652+
if (event.input !== undefined) {
653+
return event.input;
654+
}
655+
if (typeof event.argsText === "string") {
656+
return parseJsonValue(event.argsText) ?? event.argsText;
657+
}
658+
return undefined;
659+
};
660+
661+
const boundedSerializedValue = (value: unknown, maxChars: number): string =>
662+
escapeTaggedText(boundText(serializeToolValue(value), maxChars));
663+
664+
const serializeToolValue = (value: unknown): string => {
665+
if (typeof value === "string") {
666+
return value;
667+
}
668+
try {
669+
return JSON.stringify(value);
670+
} catch {
671+
return String(value);
672+
}
673+
};
674+
675+
const parseJsonObject = (value: string): Record<string, unknown> => {
676+
const parsed = parseJsonValue(value);
677+
return isRecord(parsed) ? parsed : {};
678+
};
679+
680+
const parseJsonValue = (value: string): unknown => {
681+
try {
682+
return JSON.parse(value) as unknown;
683+
} catch {
684+
return undefined;
685+
}
686+
};
687+
688+
const isToolResultError = (value: unknown): boolean =>
689+
isRecord(value)
690+
&& (value.error === true
691+
|| typeof value.error === "string"
692+
|| typeof value.errorMessage === "string"
693+
|| value.isError === true);
694+
695+
const escapeAttribute = (value: string): string =>
696+
value
697+
.replaceAll("&", "&amp;")
698+
.replaceAll("\"", "&quot;")
699+
.replaceAll("<", "&lt;")
700+
.replaceAll(">", "&gt;");
701+
702+
const escapeTaggedText = (value: string): string =>
703+
value
704+
.replaceAll("&", "&amp;")
705+
.replaceAll("<", "&lt;")
706+
.replaceAll(">", "&gt;");
707+
535708
const selectBudgetedHistory = (input: {
536709
currentUserText: string;
537710
history: ConversationMessageRecord[];
@@ -653,6 +826,9 @@ const readEventField = (event: BaseEvent, key: string): unknown => (event as Rec
653826

654827
const stringValue = (value: unknown): string | undefined => (typeof value === "string" ? value : undefined);
655828

829+
const isRecord = (value: unknown): value is Record<string, unknown> =>
830+
typeof value === "object" && value !== null && !Array.isArray(value);
831+
656832
const conversationRole = (value: unknown): AssistantDraft["role"] | undefined => {
657833
if (value === "assistant" || value === "user" || value === "system" || value === "developer") {
658834
return value;

apps/api/src/run-finalizer.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export class RunFinalizer {
5454
}
5555

5656
async cancelRun(input: { reason?: string | undefined; terminalEvent: BaseEvent }): Promise<void> {
57+
this.input.flushDraftsMemory?.();
5758
await this.syncSessionOutputs().catch(() => undefined);
5859
this.input.metadataStore.runs.updateStatus({
5960
user_id: this.input.userId,
@@ -92,6 +93,7 @@ export class RunFinalizer {
9293
}
9394

9495
fail(input: { errorMessage: string; terminalEvent: BaseEvent }): void {
96+
this.input.flushDraftsMemory?.();
9597
this.input.metadataStore.runs.updateStatus({
9698
user_id: this.input.userId,
9799
run_id: this.input.runId,

apps/api/src/run-memory-assembly.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export const createRunMemoryAssembly = async (
5757
memory: input.taskStateRuntime.memory
5858
}),
5959
repository: input.metadataStore.conversationMessages,
60+
runEvents: input.metadataStore.runEvents,
6061
sessionId: input.sessionId,
6162
summarizer: createMastraConversationSummarizer({
6263
model: input.model,

0 commit comments

Comments
 (0)