Skip to content

Commit 342ec93

Browse files
authored
Merge pull request #24 from wangjk9527/feat/production-password-auth
feat: add conversation branching checkpoints
2 parents e037d07 + 81af6d8 commit 342ec93

25 files changed

Lines changed: 1797 additions & 64 deletions

apps/api/src/config-api.ts

Lines changed: 103 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
type QueryHistoryRecord,
4141
type RunRecord,
4242
type RunEventRecord,
43+
type SessionBranchRecord,
4344
type SessionRecord,
4445
type UserRecord
4546
} from "@datafoundry/metadata";
@@ -53,6 +54,14 @@ import { resolveEffectiveRunConfig } from "./run-input.js";
5354
import { handleCapabilitiesRequest } from "./routes/capabilities.js";
5455
import type { ConfigApiContext, ConfigApiResponse } from "./routes/types.js";
5556
import { sessionTitleDto } from "./session-title.js";
57+
import {
58+
createSessionBranch,
59+
latestVisibleConversationSummary,
60+
listConversationBranchOptions,
61+
listVisibleConversationMessages,
62+
resolveSessionLineage,
63+
type ConversationBranchOption
64+
} from "./session-branching.js";
5665
import { readMultipartFiles, readMultipartUpload } from "./upload-parser.js";
5766

5867
const MAX_JSON_BODY_BYTES = 1024 * 1024;
@@ -359,6 +368,22 @@ const handleSessionRequest = async (
359368
});
360369
return ok(sessionTitleDto(session));
361370
}
371+
if (action === "branches" && request.method === "POST") {
372+
const body = await readJsonBody(request);
373+
const runId = stringValue(body.runId ?? body.run_id);
374+
if (!runId) {
375+
throw new Error("RUN_ID_REQUIRED");
376+
}
377+
const title = stringValue(body.title);
378+
const created = createSessionBranch({
379+
activeSessionId: sessionId,
380+
metadataStore: context.metadataStore,
381+
runId,
382+
...(title ? { title } : {}),
383+
userId: context.userId
384+
});
385+
return ok(sessionBranchCreatedDto(created), 201);
386+
}
362387
if (action !== "conversation") {
363388
return methodNotAllowed();
364389
}
@@ -367,16 +392,24 @@ const handleSessionRequest = async (
367392
}
368393

369394
const session = context.metadataStore.sessions.get({ user_id: context.userId, session_id: sessionId });
395+
const lineage = resolveSessionLineage({
396+
metadataStore: context.metadataStore,
397+
sessionId,
398+
userId: context.userId
399+
});
370400
const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1");
371401
const limit = clampInteger(Number.parseInt(requestUrl.searchParams.get("limit") ?? "", 10), 1, 200, 80);
372-
const messages = context.metadataStore.conversationMessages.listRecent({
373-
user_id: context.userId,
374-
session_id: sessionId,
375-
limit
402+
const messages = listVisibleConversationMessages({
403+
lineage,
404+
limit,
405+
metadataStore: context.metadataStore,
406+
userId: context.userId
376407
});
377-
const latestSummary = context.metadataStore.conversationSummaries.latest({
378-
user_id: context.userId,
379-
session_id: sessionId
408+
const latestSummary = latestVisibleConversationSummary({
409+
lineage,
410+
metadataStore: context.metadataStore,
411+
sessionId,
412+
userId: context.userId
380413
});
381414
const runIds = [...new Set([
382415
...messages.map((message) => message.run_id),
@@ -386,11 +419,19 @@ const handleSessionRequest = async (
386419
runId,
387420
events: context.metadataStore.runEvents.listByRun({ user_id: context.userId, run_id: runId })
388421
}));
422+
const visiblePositions = new Map(messages.map((message, index) => [message.id, index + 1]));
389423
const checkpoints = runCheckpointDtos({
390424
context,
391425
messages,
392426
runEventGroups,
393-
runIds
427+
runIds,
428+
visiblePositions
429+
});
430+
const branchOptions = listConversationBranchOptions({
431+
lineage,
432+
metadataStore: context.metadataStore,
433+
sessionId,
434+
userId: context.userId
394435
});
395436
const pendingInteractions = context.metadataStore.interactions.listPendingBySession({
396437
user_id: context.userId,
@@ -402,10 +443,12 @@ const handleSessionRequest = async (
402443
title: session.title ?? "",
403444
titleSource: session.title_source ?? "fallback",
404445
updatedAt: session.updated_at,
405-
messages: messages.map(conversationMessageDto),
446+
messages: messages.map((message, index) => conversationMessageDto(message, index + 1)),
406447
...(latestSummary ? { summary: conversationSummaryDto(latestSummary) } : {}),
407448
runEventRefs: runEventGroups.map(({ runId, events }) => runEventRefDto(runId, events)),
408449
...(checkpoints.length > 0 ? { checkpoints } : {}),
450+
...(lineage.branch ? { branch: sessionBranchDto(lineage.branch, session) } : {}),
451+
...(branchOptions.length > 0 ? { branches: branchOptions.map(conversationBranchOptionDto) } : {}),
409452
toolCalls: runEventGroups.flatMap(({ runId, events }) => toolCallPairDtos(runId, events)),
410453
pendingInteractions: pendingInteractions.map(pendingInteractionDto),
411454
restorableCustomEvents: runEventGroups.flatMap(({ runId, events }) =>
@@ -1616,14 +1659,14 @@ const handleArtifactRequest = async (
16161659
});
16171660
};
16181661

1619-
const conversationMessageDto = (message: ConversationMessageRecord): Record<string, unknown> => ({
1662+
const conversationMessageDto = (message: ConversationMessageRecord, visiblePosition = message.position): Record<string, unknown> => ({
16201663
id: message.id,
16211664
runId: message.run_id,
16221665
role: message.role,
16231666
source: message.source,
16241667
...(message.message_id ? { messageId: message.message_id } : {}),
16251668
contentText: message.content_text,
1626-
position: message.position,
1669+
position: visiblePosition,
16271670
createdAt: message.created_at
16281671
});
16291672

@@ -1637,6 +1680,38 @@ const sessionListDto = (session: SessionRecord): Record<string, unknown> => ({
16371680
lastMessageAt: session.last_message_at ?? session.updated_at
16381681
});
16391682

1683+
const sessionBranchCreatedDto = (input: { branch: SessionBranchRecord; session: SessionRecord }): Record<string, unknown> => ({
1684+
...sessionBranchDto(input.branch, input.session),
1685+
session: sessionListDto(input.session)
1686+
});
1687+
1688+
const sessionBranchDto = (
1689+
branch: SessionBranchRecord,
1690+
session?: SessionRecord
1691+
): Record<string, unknown> => ({
1692+
id: branch.id,
1693+
sessionId: branch.child_session_id,
1694+
threadId: branch.child_session_id,
1695+
parentSessionId: branch.parent_session_id,
1696+
rootSessionId: branch.root_session_id,
1697+
forkRunId: branch.fork_run_id,
1698+
forkMessageEndPosition: branch.fork_message_end_position,
1699+
createdAt: branch.created_at,
1700+
...(session?.title ? { title: session.title } : {})
1701+
});
1702+
1703+
const conversationBranchOptionDto = (branch: ConversationBranchOption): Record<string, unknown> => ({
1704+
sessionId: branch.sessionId,
1705+
threadId: branch.sessionId,
1706+
parentSessionId: branch.parentSessionId,
1707+
rootSessionId: branch.rootSessionId,
1708+
forkRunId: branch.forkRunId,
1709+
forkMessageEndPosition: branch.forkMessageEndPosition,
1710+
isOriginal: branch.isOriginal,
1711+
createdAt: branch.createdAt,
1712+
...(branch.title ? { title: branch.title } : {})
1713+
});
1714+
16401715
const queryHistoryDto = (record: QueryHistoryRecord): Record<string, unknown> => ({
16411716
id: record.id,
16421717
sessionId: record.session_id,
@@ -1689,6 +1764,7 @@ const runCheckpointDtos = (input: {
16891764
messages: ConversationMessageRecord[];
16901765
runEventGroups: Array<{ events: RunEventRecord[]; runId: string }>;
16911766
runIds: string[];
1767+
visiblePositions?: Map<string, number>;
16921768
}): Record<string, unknown>[] => {
16931769
const eventsByRun = new Map(input.runEventGroups.map((group) => [group.runId, group.events]));
16941770
return input.runIds.flatMap((runId) => {
@@ -1703,7 +1779,8 @@ const runCheckpointDtos = (input: {
17031779
runCheckpointDto({
17041780
events: eventsByRun.get(runId) ?? [],
17051781
messages: input.messages.filter((message) => message.run_id === runId),
1706-
run
1782+
run,
1783+
...(input.visiblePositions ? { visiblePositions: input.visiblePositions } : {})
17071784
})
17081785
];
17091786
});
@@ -1713,8 +1790,9 @@ const runCheckpointDto = (input: {
17131790
events: RunEventRecord[];
17141791
messages: ConversationMessageRecord[];
17151792
run: RunRecord;
1793+
visiblePositions?: Map<string, number>;
17161794
}): Record<string, unknown> => {
1717-
const positions = input.messages.map((message) => message.position);
1795+
const positions = input.messages.map((message) => input.visiblePositions?.get(message.id) ?? message.position);
17181796
const firstEvent = input.events[0];
17191797
const lastEvent = input.events.at(-1);
17201798
return {
@@ -2670,6 +2748,18 @@ const errorResponse = (error: unknown): ConfigApiResponse => {
26702748
if (message.startsWith("REVISION_CONFLICT")) {
26712749
return fail(409, "REVISION_CONFLICT", message);
26722750
}
2751+
if (message.startsWith("RUN_NOT_BRANCHABLE")) {
2752+
return fail(409, "RUN_NOT_BRANCHABLE", message);
2753+
}
2754+
if (message.startsWith("RUN_NOT_VISIBLE")) {
2755+
return fail(404, "RESOURCE_NOT_FOUND", message);
2756+
}
2757+
if (message.startsWith("SESSION_BRANCH_CYCLE")) {
2758+
return fail(409, "REVISION_CONFLICT", message);
2759+
}
2760+
if (message.startsWith("SESSION_BRANCH_PARENT_NOT_VISIBLE")) {
2761+
return fail(404, "RESOURCE_NOT_FOUND", message);
2762+
}
26732763
if (message.includes("NOT_FOUND") || message.includes("not found")) {
26742764
return fail(404, "RESOURCE_NOT_FOUND", message);
26752765
}

apps/api/src/conversation-memory.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,19 @@ export type ConversationMemoryRunMessages = {
5050
report: ConversationMemoryWindowReport;
5151
};
5252

53+
export type ConversationMemoryHistoryProvider = (input: {
54+
excludeRunId: string;
55+
limit: number;
56+
sessionId: string;
57+
userId: string;
58+
}) => {
59+
history: ConversationMessageRecord[];
60+
summary?: ConversationSummaryRecord;
61+
};
62+
5363
export type ConversationMemoryServiceInput = {
5464
compactMemorySource?: ConversationCompactMemorySource | undefined;
65+
historyProvider?: ConversationMemoryHistoryProvider | undefined;
5566
memoryBridge?: ConversationMemoryBridge | undefined;
5667
policy?: ConversationMemoryWindowPolicy | undefined;
5768
repository: ConversationMessageRepository;
@@ -136,16 +147,24 @@ export class ConversationMemoryService {
136147
runInput: RunAgentInput;
137148
}): ConversationMemoryRunMessages {
138149
const policy = normalizePolicy(this.input.policy);
139-
const history = this.input.repository.listRecent({
150+
const provided = this.input.historyProvider?.({
151+
excludeRunId: input.runId,
152+
limit: policy.historyLoadLimit,
153+
sessionId: this.input.sessionId,
154+
userId: this.input.userId
155+
});
156+
const history = provided?.history ?? this.input.repository.listRecent({
140157
user_id: this.input.userId,
141158
session_id: this.input.sessionId,
142159
exclude_run_id: input.runId,
143160
limit: policy.historyLoadLimit
144161
});
145-
const summary = this.input.summaryRepository?.latest({
146-
user_id: this.input.userId,
147-
session_id: this.input.sessionId
148-
});
162+
const summary = provided
163+
? provided.summary
164+
: this.input.summaryRepository?.latest({
165+
user_id: this.input.userId,
166+
session_id: this.input.sessionId
167+
});
149168
const effectiveHistory = summary ? history.filter((record) => record.position > summary.to_position) : history;
150169
const toolCheckpoints = this.input.runEvents
151170
? buildToolCheckpointsForHistory({

apps/api/src/run-identity.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import type { BaseEvent, RunAgentInput } from "@ag-ui/client";
22
import { type MetadataStore, type RunEventWriter, type RunRecord } from "@datafoundry/metadata";
33
import { createHash } from "node:crypto";
4+
import {
5+
isRunVisibleInSessionLineage,
6+
resolveSessionLineage
7+
} from "./session-branching.js";
48

59
export type ResolveExistingRunInput = {
610
existingRun: RunRecord;
@@ -62,7 +66,19 @@ export const validateParentRun = ({
6266
if (!parentRun) {
6367
throw new Error(`PARENT_RUN_NOT_FOUND: ${parentRunId}`);
6468
}
65-
if (parentRun.session_id !== sessionId) {
69+
if (parentRun.session_id === sessionId) {
70+
return;
71+
}
72+
73+
try {
74+
const lineage = resolveSessionLineage({ metadataStore, sessionId, userId });
75+
if (!isRunVisibleInSessionLineage({ lineage, run: parentRun })) {
76+
throw new Error(`PARENT_RUN_SESSION_MISMATCH: ${parentRunId}`);
77+
}
78+
} catch (error) {
79+
if (error instanceof Error && error.message.includes("PARENT_RUN_SESSION_MISMATCH")) {
80+
throw error;
81+
}
6682
throw new Error(`PARENT_RUN_SESSION_MISMATCH: ${parentRunId}`);
6783
}
6884
};

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ import {
1616
type ConversationMemoryEventObserver
1717
} from "./conversation-memory.js";
1818
import { createMastraConversationSummarizer } from "./conversation-summarizer.js";
19+
import {
20+
latestVisibleConversationSummary,
21+
listVisibleConversationMessages,
22+
resolveSessionLineage
23+
} from "./session-branching.js";
1924
import { LongTermMemoryService } from "./long-term-memory.js";
2025
import { createMastraLongTermMemoryExtractor } from "./long-term-memory-extractor.js";
2126

@@ -56,6 +61,29 @@ export const createRunMemoryAssembly = async (
5661
: createMastraConversationMemoryBridge({
5762
memory: input.taskStateRuntime.memory
5863
}),
64+
historyProvider: ({ excludeRunId, limit, sessionId, userId }) => {
65+
const lineage = resolveSessionLineage({
66+
metadataStore: input.metadataStore,
67+
sessionId,
68+
userId
69+
});
70+
const summary = latestVisibleConversationSummary({
71+
lineage,
72+
metadataStore: input.metadataStore,
73+
sessionId,
74+
userId
75+
});
76+
return {
77+
history: listVisibleConversationMessages({
78+
excludeRunId,
79+
lineage,
80+
limit,
81+
metadataStore: input.metadataStore,
82+
userId
83+
}),
84+
...(summary ? { summary } : {})
85+
};
86+
},
5987
repository: input.metadataStore.conversationMessages,
6088
runEvents: input.metadataStore.runEvents,
6189
sessionId: input.sessionId,

apps/api/src/server.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
copilotRuntimeNodeHttpEndpoint
66
} from "@copilotkit/runtime";
77
import {
8+
CONVERSATION_WORKING_MEMORY_CONFIG,
89
createTaskStateRuntime,
910
createCustomEvent,
1011
parseAgentMemoryMode,
@@ -631,6 +632,14 @@ class DataFoundryAgUiAgent extends AbstractAgent {
631632
});
632633
subscriber.add(() => unregisterCancel());
633634

635+
if (this.input.conversationMemoryMode === "working-memory-readonly") {
636+
await ensureConversationWorkingMemoryThread({
637+
resourceId: this.input.user.id,
638+
taskStateRuntime: this.input.taskStateRuntime,
639+
threadId: sessionId
640+
});
641+
}
642+
634643
subscription = agentAssembly.mastraAgent.run({
635644
...normalizedRunInput,
636645
runId,
@@ -891,6 +900,7 @@ const resolveRequestAuth = (
891900
workspaceId: identity.workspace.id
892901
};
893902
}
903+
894904
const token = extractAuthToken(request);
895905
const workspaceId = sanitizeWorkspaceId(headerString(request.headers["x-workspace-id"]));
896906
const devUser = metadataStore.users.getById({ user_id: DEV_USER.id });
@@ -922,6 +932,26 @@ const resolveRequestAuth = (
922932
};
923933
};
924934

935+
const ensureConversationWorkingMemoryThread = async (input: {
936+
resourceId: string;
937+
taskStateRuntime: TaskStateRuntime;
938+
threadId: string;
939+
}): Promise<void> => {
940+
const existing = await input.taskStateRuntime.memory.getThreadById({
941+
resourceId: input.resourceId,
942+
threadId: input.threadId
943+
});
944+
if (existing) {
945+
return;
946+
}
947+
await input.taskStateRuntime.memory.createThread({
948+
memoryConfig: CONVERSATION_WORKING_MEMORY_CONFIG,
949+
resourceId: input.resourceId,
950+
saveThread: true,
951+
threadId: input.threadId
952+
});
953+
};
954+
925955
const isPasswordAuth = (config: PasswordAuthConfig): boolean => config.mode === "password";
926956

927957
const extractAuthToken = (request: IncomingMessage): string | undefined => {

0 commit comments

Comments
 (0)