feat: project durable agent conversations

This commit is contained in:
-Puter
2026-07-29 08:19:17 +05:30
parent 18eb150d7d
commit 3ffa1cfc7c
22 changed files with 800 additions and 309 deletions

View File

@@ -0,0 +1,16 @@
import { AsyncLocalStorage } from "node:async_hooks";
export interface TurnAdmissionContext {
readonly clientRequestId: string;
readonly turnId: string;
}
const turnAdmissionContext = new AsyncLocalStorage<TurnAdmissionContext>();
export const currentTurnAdmission = (): TurnAdmissionContext | undefined =>
turnAdmissionContext.getStore();
export const withTurnAdmission = <T>(
context: TurnAdmissionContext,
run: () => T
): T => turnAdmissionContext.run(context, run);

View File

@@ -80,6 +80,7 @@ app.post("/internal/work-attempts/:workspaceKey/cancel", async (context) => {
});
app.all("/api/rivet/*", (context) => runtimeRegistry.handler(context.req.raw));
app.route("/", flue());
export default app satisfies Fetchable;

View File

@@ -1,6 +1,8 @@
import { parseAgentEnv } from "@code/env/agent";
import type { AgentRouteHandler } from "@flue/runtime";
import { withTurnAdmission } from "./admission-context";
/** Only Convex may invoke or observe the organization-scoped Flue agent. */
export const convexAgentRoute: AgentRouteHandler = async (context, next) => {
const env = parseAgentEnv(process.env);
@@ -14,5 +16,15 @@ export const convexAgentRoute: AgentRouteHandler = async (context, next) => {
return context.json({ error: "Forbidden" }, 403);
}
return await next().then(() => context.res);
if (context.req.method !== "POST") {
return await next();
}
const clientRequestId = context.req.header("x-zopu-request-id");
const turnId = context.req.header("x-zopu-turn-id");
if (!clientRequestId || !turnId) {
return context.json({ error: "Missing turn correlation headers" }, 400);
}
return await withTurnAdmission({ clientRequestId, turnId }, () => next());
};

View File

@@ -26,11 +26,74 @@
*/
import { env } from "@code/env/server";
import { AttachmentConflictError, DEFAULT_LIST_LIMIT, DEFAULT_READ_LIMIT, MAX_LIST_LIMIT, MAX_READ_LIMIT, StreamListenerRegistry, assertSupportedFlueSchemaVersion, clampLimit, copyAttachmentBytes, createSessionStorageKey, decodeRunCursor, encodeRunCursor, formatOffset, hydratePersistedDirectSubmission, parseOffset, prepareDirectSubmission, verifyAttachmentBytes } from '@flue/runtime/adapter';
import type { AgentAttemptMarker, AgentDispatchAdmission, AgentDispatchReceipt, AgentExecutionStore, AgentSubmission, AgentSubmissionStore, AttachmentRef, AttachmentStore, ConversationProducerClaim, ConversationRecord, ConversationStreamBatch, ConversationStreamIdentity, ConversationStreamMeta, ConversationStreamReadResult, ConversationStreamStore, CreateRunInput, DirectAgentSubmissionInput, DispatchAgentSubmissionInput, DispatchInput, EndRunInput, EventStreamMeta, EventStreamReadResult, EventStreamStore, GetAttachmentInput, PersistedChunkRow, PersistenceAdapter, PersistenceStores, PutAttachmentInput, RunPointer, RunRecord, RunStore, RunStatus, StoredAttachment, SubmissionAttemptRef, SubmissionClaimRef, SubmissionDurability, SubmissionSettlementObligation, SubmissionSettledRecord } from '@flue/runtime/adapter';
import {
AttachmentConflictError,
DEFAULT_LIST_LIMIT,
DEFAULT_READ_LIMIT,
MAX_LIST_LIMIT,
MAX_READ_LIMIT,
StreamListenerRegistry,
assertSupportedFlueSchemaVersion,
clampLimit,
copyAttachmentBytes,
createSessionStorageKey,
decodeRunCursor,
encodeRunCursor,
formatOffset,
hydratePersistedDirectSubmission,
parseOffset,
prepareDirectSubmission,
verifyAttachmentBytes,
} from "@flue/runtime/adapter";
import type {
AgentAttemptMarker,
AgentDispatchAdmission,
AgentDispatchReceipt,
AgentExecutionStore,
AgentSubmission,
AgentSubmissionStore,
AttachmentRef,
AttachmentStore,
ConversationProducerClaim,
ConversationRecord,
ConversationStreamBatch,
ConversationStreamIdentity,
ConversationStreamMeta,
ConversationStreamReadResult,
ConversationStreamStore,
CreateRunInput,
DirectAgentSubmissionInput,
DispatchAgentSubmissionInput,
DispatchInput,
EndRunInput,
EventStreamMeta,
EventStreamReadResult,
EventStreamStore,
GetAttachmentInput,
PersistedChunkRow,
PersistenceAdapter,
PersistenceStores,
PutAttachmentInput,
RunPointer,
RunRecord,
RunStore,
RunStatus,
StoredAttachment,
SubmissionAttemptRef,
SubmissionClaimRef,
SubmissionDurability,
SubmissionSettlementObligation,
SubmissionSettledRecord,
} from "@flue/runtime/adapter";
import { ConvexHttpClient } from "convex/browser";
import { makeFunctionReference } from 'convex/server';
import type { FunctionArgs, FunctionReference, FunctionReturnType } from 'convex/server';
import { makeFunctionReference } from "convex/server";
import type {
FunctionArgs,
FunctionReference,
FunctionReturnType,
} from "convex/server";
import { currentTurnAdmission } from "./admission-context";
// ---------------------------------------------------------------------------
// Token + arg types
@@ -42,7 +105,10 @@ import type { FunctionArgs, FunctionReference, FunctionReturnType } from 'convex
// ---------------------------------------------------------------------------
/** Auth token present on every Convex function call. */
interface TokenArgs { readonly token: string; readonly [key: string]: unknown }
interface TokenArgs {
readonly token: string;
readonly [key: string]: unknown;
}
const TOKEN = (): string => env.FLUE_DB_TOKEN;
@@ -152,7 +218,7 @@ interface AttachmentRefWire {
// ---------------------------------------------------------------------------
const checkSchemaVersion = makeFunctionReference<"query", TokenArgs, string>(
"fluePersistence:checkSchemaVersion",
"fluePersistence:checkSchemaVersion"
);
const getSubmission = makeFunctionReference<
@@ -221,7 +287,11 @@ interface AdmitSubmissionResponse {
}
const admitSubmission = makeFunctionReference<
"mutation",
TokenArgs & { readonly input: AdmitSubmissionEnvelope },
TokenArgs & {
readonly clientRequestId?: string;
readonly input: AdmitSubmissionEnvelope;
readonly turnId?: string;
},
AdmitSubmissionResponse
>("fluePersistence:admitSubmission");
@@ -290,11 +360,9 @@ type SettleArgs = TokenArgs &
readonly outcome: "completed" | "failed";
readonly errorJson?: string;
};
const settleSubmission = makeFunctionReference<
"mutation",
SettleArgs,
boolean
>("fluePersistence:settleSubmission");
const settleSubmission = makeFunctionReference<"mutation", SettleArgs, boolean>(
"fluePersistence:settleSubmission"
);
const insertAttemptMarker = makeFunctionReference<
"mutation",
@@ -349,10 +417,16 @@ type AppendBatchArgs = TokenArgs & {
readonly producerEpoch: number;
readonly incarnation: string;
readonly producerSequence: number;
readonly submission?: { readonly submissionId: string; readonly attemptId: string };
readonly submission?: {
readonly submissionId: string;
readonly attemptId: string;
};
readonly recordsJson: string;
};
interface AppendResult { readonly offset: number; readonly appended: boolean }
interface AppendResult {
readonly offset: number;
readonly appended: boolean;
}
const appendConversationBatch = makeFunctionReference<
"mutation",
AppendBatchArgs,
@@ -438,7 +512,10 @@ const closeEventStream = makeFunctionReference<
void
>("fluePersistence:closeEventStream");
interface EventStreamMetaRow { readonly nextOffset: number; readonly closed: boolean }
interface EventStreamMetaRow {
readonly nextOffset: number;
readonly closed: boolean;
}
const getEventStreamMeta = makeFunctionReference<
"query",
TokenArgs & { readonly path: string },
@@ -454,7 +531,7 @@ type CreateRunArgs = TokenArgs & {
readonly traceCarrierJson?: string;
};
const createRun = makeFunctionReference<"mutation", CreateRunArgs, void>(
"fluePersistence:createRun",
"fluePersistence:createRun"
);
type EndRunArgs = TokenArgs & {
@@ -466,7 +543,7 @@ type EndRunArgs = TokenArgs & {
readonly errorJson?: string;
};
const endRun = makeFunctionReference<"mutation", EndRunArgs, void>(
"fluePersistence:endRun",
"fluePersistence:endRun"
);
const getRun = makeFunctionReference<
@@ -492,7 +569,7 @@ interface ListRunsResult {
readonly hasMore: boolean;
}
const listRuns = makeFunctionReference<"query", ListRunsArgs, ListRunsResult>(
"fluePersistence:listRuns",
"fluePersistence:listRuns"
);
// Attachments
@@ -534,7 +611,9 @@ const deleteAttachmentsForInstance = makeFunctionReference<
// ---------------------------------------------------------------------------
const safeJsonParse = <T>(text: string | null | undefined): T | undefined => {
if (text === null || text === undefined || text === "") {return undefined;}
if (text === null || text === undefined || text === "") {
return undefined;
}
return JSON.parse(text) as T;
};
@@ -544,7 +623,7 @@ const parseAcceptedAt = (value: string, label: string): number => {
const ms = Date.parse(value);
if (!Number.isFinite(ms)) {
throw new TypeError(
`[flue] ${label} produced non-finite acceptedAt: ${value}`,
`[flue] ${label} produced non-finite acceptedAt: ${value}`
);
}
return ms;
@@ -610,7 +689,7 @@ const hydrateSubmission = (row: SubmissionRow): AgentSubmission => {
const input = safeJsonParse<DispatchAgentSubmissionInput>(row.inputJson);
if (input === undefined) {
throw new Error(
`[flue] persisted dispatch submission ${row.submissionId} has empty inputJson`,
`[flue] persisted dispatch submission ${row.submissionId} has empty inputJson`
);
}
const inputWithCarrier =
@@ -621,7 +700,7 @@ const hydrateSubmission = (row: SubmissionRow): AgentSubmission => {
const stripped = safeJsonParse<DirectAgentSubmissionInput>(row.inputJson);
if (stripped === undefined) {
throw new Error(
`[flue] persisted direct submission ${row.submissionId} has empty inputJson`,
`[flue] persisted direct submission ${row.submissionId} has empty inputJson`
);
}
const chunks = safeJsonParse<PersistedChunkRow[]>(row.chunksJson) ?? [];
@@ -632,12 +711,12 @@ const hydrateSubmission = (row: SubmissionRow): AgentSubmission => {
};
const hydrateObligation = (
row: SettlementObligationRow,
row: SettlementObligationRow
): SubmissionSettlementObligation => {
const record = safeJsonParse<SubmissionSettledRecord>(row.recordJson);
if (record === undefined) {
throw new Error(
`[flue] persisted settlement obligation ${row.submissionId} has empty recordJson`,
`[flue] persisted settlement obligation ${row.submissionId} has empty recordJson`
);
}
return {
@@ -692,14 +771,14 @@ class ConvexClient {
query<F extends FunctionReference<"query">>(
ref: F,
args: FunctionArgs<F>,
args: FunctionArgs<F>
): Promise<FunctionReturnType<F>> {
return this.client.query(ref, args);
}
mutation<F extends FunctionReference<"mutation">>(
ref: F,
args: FunctionArgs<F>,
args: FunctionArgs<F>
): Promise<FunctionReturnType<F>> {
return this.client.mutation(ref, args);
}
@@ -757,7 +836,7 @@ class ConvexAgentSubmissionStore implements AgentSubmissionStore {
async replaceSubmissionAttempt(
attempt: SubmissionAttemptRef,
nextAttemptId: string,
lease?: { ownerId: string; leaseExpiresAt: number },
lease?: { ownerId: string; leaseExpiresAt: number }
): Promise<AgentSubmission | null> {
const row = await this.convex.mutation(replaceSubmissionAttempt, {
attemptId: attempt.attemptId,
@@ -808,10 +887,11 @@ class ConvexAgentSubmissionStore implements AgentSubmissionStore {
}
async admitDirect(
input: DirectAgentSubmissionInput,
input: DirectAgentSubmissionInput
): Promise<AgentSubmission> {
const sessionKey = createSessionStorageKey(input.id, "default", "default");
const extracted = prepareDirectSubmission(input);
const admission = currentTurnAdmission();
const res = await this.convex.mutation(admitSubmission, {
input: {
acceptedAt: parseAcceptedAt(input.acceptedAt, "admitDirect"),
@@ -825,6 +905,12 @@ class ConvexAgentSubmissionStore implements AgentSubmissionStore {
: { traceCarrierJson: jsonStringify(input.traceCarrier) }),
},
token: TOKEN(),
...(admission === undefined
? {}
: {
clientRequestId: admission.clientRequestId,
turnId: admission.turnId,
}),
});
if (res.kind === "submission" && res.submission !== undefined) {
return hydrateSubmission(res.submission);
@@ -833,12 +919,12 @@ class ConvexAgentSubmissionStore implements AgentSubmissionStore {
// submission; if the backend signals a non-submission result we cannot
// satisfy the `AgentSubmission` return type.
throw new Error(
`[flue] admitDirect for ${input.submissionId} did not return a submission`,
`[flue] admitDirect for ${input.submissionId} did not return a submission`
);
}
async markSubmissionCanonicalReady(
submissionId: string,
submissionId: string
): Promise<AgentSubmission | null> {
const row = await this.convex.mutation(markSubmissionCanonicalReady, {
submissionId,
@@ -848,7 +934,7 @@ class ConvexAgentSubmissionStore implements AgentSubmissionStore {
}
async claimSubmission(
claim: SubmissionClaimRef,
claim: SubmissionClaimRef
): Promise<AgentSubmission | null> {
const row = await this.convex.mutation(claimSubmission, {
attemptId: claim.attemptId,
@@ -862,7 +948,7 @@ class ConvexAgentSubmissionStore implements AgentSubmissionStore {
markSubmissionInputApplied(
attempt: SubmissionAttemptRef,
durability?: SubmissionDurability,
durability?: SubmissionDurability
): Promise<boolean> {
return this.convex.mutation(markSubmissionInputApplied, {
attemptId: attempt.attemptId,
@@ -872,9 +958,7 @@ class ConvexAgentSubmissionStore implements AgentSubmissionStore {
});
}
requestSubmissionRecovery(
attempt: SubmissionAttemptRef,
): Promise<boolean> {
requestSubmissionRecovery(attempt: SubmissionAttemptRef): Promise<boolean> {
return this.convex.mutation(requestSubmissionRecovery, {
attemptId: attempt.attemptId,
submissionId: attempt.submissionId,
@@ -890,7 +974,7 @@ class ConvexAgentSubmissionStore implements AgentSubmissionStore {
}
requeueSubmissionBeforeInputApplied(
attempt: SubmissionAttemptRef,
attempt: SubmissionAttemptRef
): Promise<boolean> {
return this.convex.mutation(requeueSubmissionBeforeInputApplied, {
attemptId: attempt.attemptId,
@@ -901,7 +985,7 @@ class ConvexAgentSubmissionStore implements AgentSubmissionStore {
async reserveSubmissionSettlement(
attempt: SubmissionAttemptRef,
settlement: { recordId: string; record: SubmissionSettledRecord },
settlement: { recordId: string; record: SubmissionSettledRecord }
): Promise<SubmissionSettlementObligation | null> {
const row = await this.convex.mutation(reserveSubmissionSettlement, {
attemptId: attempt.attemptId,
@@ -915,7 +999,7 @@ class ConvexAgentSubmissionStore implements AgentSubmissionStore {
finalizeSubmissionSettlement(
attempt: SubmissionAttemptRef,
recordId: string,
recordId: string
): Promise<boolean> {
return this.convex.mutation(finalizeSubmissionSettlement, {
attemptId: attempt.attemptId,
@@ -936,7 +1020,7 @@ class ConvexAgentSubmissionStore implements AgentSubmissionStore {
failSubmission(
attempt: SubmissionAttemptRef,
error: unknown,
error: unknown
): Promise<boolean> {
return this.convex.mutation(settleSubmission, {
attemptId: attempt.attemptId,
@@ -1001,7 +1085,7 @@ class ConvexConversationStreamStore implements ConversationStreamStore {
async createStream(
path: string,
identity: ConversationStreamIdentity,
identity: ConversationStreamIdentity
): Promise<void> {
await this.convex.mutation(createConversationStream, {
identity,
@@ -1012,7 +1096,7 @@ class ConvexConversationStreamStore implements ConversationStreamStore {
acquireProducer(
path: string,
producerId: string,
producerId: string
): Promise<ConversationProducerClaim> {
return this.convex.mutation(acquireConversationProducer, {
path,
@@ -1038,7 +1122,9 @@ class ConvexConversationStreamStore implements ConversationStreamStore {
producerSequence: input.producerSequence,
recordsJson: jsonStringify(input.records),
token: TOKEN(),
...(input.submission === undefined ? {} : { submission: input.submission }),
...(input.submission === undefined
? {}
: { submission: input.submission }),
});
if (result.appended) {
this.listeners.notify(input.path);
@@ -1048,12 +1134,12 @@ class ConvexConversationStreamStore implements ConversationStreamStore {
async read(
path: string,
options?: { offset?: string; limit?: number },
options?: { offset?: string; limit?: number }
): Promise<ConversationStreamReadResult> {
const limit = clampLimit(
options?.limit,
DEFAULT_READ_LIMIT,
MAX_READ_LIMIT,
MAX_READ_LIMIT
);
const result = await this.convex.query(readConversationBatches, {
afterOffset: afterOffset(options?.offset),
@@ -1077,7 +1163,9 @@ class ConvexConversationStreamStore implements ConversationStreamStore {
path,
token: TOKEN(),
});
if (row === null) {return null;}
if (row === null) {
return null;
}
return {
identity: row.identity,
incarnation: row.incarnation,
@@ -1131,7 +1219,7 @@ class ConvexEventStreamStore implements EventStreamStore {
async appendEventOnce(
path: string,
key: string,
event: unknown,
event: unknown
): Promise<string> {
const result = await this.convex.mutation(appendEventOnce, {
dataJson: jsonStringify(event),
@@ -1147,7 +1235,7 @@ class ConvexEventStreamStore implements EventStreamStore {
async readEvents(
path: string,
opts?: { offset?: string; limit?: number },
opts?: { offset?: string; limit?: number }
): Promise<EventStreamReadResult> {
const limit = clampLimit(opts?.limit, DEFAULT_READ_LIMIT, MAX_READ_LIMIT);
const result = await this.convex.query(readEventsFn, {
@@ -1179,7 +1267,9 @@ class ConvexEventStreamStore implements EventStreamStore {
path,
token: TOKEN(),
});
if (row === null) {return null;}
if (row === null) {
return null;
}
return {
closed: row.closed,
nextOffset: formatOffset(row.nextOffset),
@@ -1233,7 +1323,7 @@ class ConvexRunStore implements RunStore {
}
lookupRun(
runId: string,
runId: string
): Promise<{ runId: string; workflowName: string } | null> {
return this.convex.query(lookupRun, { runId, token: TOKEN() });
}
@@ -1281,7 +1371,7 @@ class ConvexAttachmentStore implements AttachmentStore {
attachment: attachmentRefToWire(input.attachment),
bytes: bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength,
bytes.byteOffset + bytes.byteLength
) as ArrayBuffer,
conversationId: input.conversationId,
streamPath: input.streamPath,
@@ -1302,7 +1392,9 @@ class ConvexAttachmentStore implements AttachmentStore {
streamPath: input.streamPath,
token: TOKEN(),
});
if (row === null) {return null;}
if (row === null) {
return null;
}
const attachment = wireRefToAttachmentRef(row.attachment);
const bytes = new Uint8Array(row.bytes);
// Verify integrity before handing bytes back to the runtime; throws
@@ -1345,7 +1437,7 @@ class ConvexPersistenceAdapterImpl implements PersistenceAdapter {
connect(): PersistenceStores {
if (!this.migrated) {
throw new Error(
"[flue] ConvexPersistenceAdapter.connect() called before migrate() completed",
"[flue] ConvexPersistenceAdapter.connect() called before migrate() completed"
);
}
const submissions = new ConvexAgentSubmissionStore(this.convex);