/** * March Agent SDK - LangGraph Extension * * HTTPCheckpointSaver for LangGraph that stores state via HTTP API. * * @packageDocumentation */ import type { MarchAgentApp } from '../app.js' import { CheckpointClient } from '../checkpoint-client.js' import type { CheckpointConfig, CheckpointData, CheckpointMetadata as APICheckpointMetadata, CheckpointTuple as APICheckpointTuple, } from '../checkpoint-client.js' // Type definitions for LangGraph (optional peer dependency) // These are compatible with @langchain/langgraph-checkpoint interface RunnableConfig { configurable?: { thread_id?: string checkpoint_ns?: string checkpoint_id?: string } } interface Checkpoint { v?: number id?: string ts?: string channel_values?: Record channel_versions?: Record versions_seen?: Record> pending_sends?: unknown[] } interface CheckpointMetadata { source?: string step?: number writes?: unknown parents?: Record } interface CheckpointTuple { config: RunnableConfig checkpoint: Checkpoint metadata: CheckpointMetadata parent_config?: RunnableConfig pending_writes?: unknown[] } interface PendingWrite { [key: string]: unknown } /** * HTTP-based checkpoint saver for LangGraph. * * Stores graph state via HTTP calls to the conversation-store checkpoint API, * enabling distributed checkpoint storage without direct database access. * * @example * ```typescript * import { MarchAgentApp } from 'march-ai-sdk' * import { HTTPCheckpointSaver } from 'march-ai-sdk/extensions/langgraph' * import { StateGraph } from '@langchain/langgraph' * * const app = new MarchAgentApp({ * gatewayUrl: 'agent-gateway:8080', * apiKey: 'your-key', * }) * * const checkpointer = new HTTPCheckpointSaver(app) * * const graph = new StateGraph(MyState) * // ... define graph ... * const compiled = graph.compile({ checkpointer }) * * const config = { configurable: { thread_id: 'my-thread' } } * const result = await compiled.invoke({ messages: [...] }, config) * ``` */ export class HTTPCheckpointSaver { private readonly client: CheckpointClient constructor(app: MarchAgentApp) { this.client = new CheckpointClient(app.gatewayClient.conversationStoreUrl) } /** * Get thread_id from config. */ private getThreadId(config: RunnableConfig): string { const threadId = config.configurable?.thread_id if (!threadId) { throw new Error('Config must contain configurable.thread_id') } return threadId } /** * Get checkpoint_ns from config. */ private getCheckpointNs(config: RunnableConfig): string { return config.configurable?.checkpoint_ns ?? '' } /** * Get checkpoint_id from config. */ private getCheckpointId(config: RunnableConfig): string | undefined { return config.configurable?.checkpoint_id } /** * Generate a unique checkpoint ID. */ private generateCheckpointId(): string { return new Date().toISOString() } /** * Fetch a checkpoint tuple asynchronously. */ async getTuple(config: RunnableConfig): Promise { const threadId = this.getThreadId(config) const checkpointNs = this.getCheckpointNs(config) const checkpointId = this.getCheckpointId(config) const result = await this.client.getTuple(threadId, checkpointNs, checkpointId) if (!result) { return undefined } return this.responseToTuple(result) } /** * List checkpoints asynchronously. */ async *list( config: RunnableConfig | undefined, options?: { filter?: Record before?: RunnableConfig limit?: number } ): AsyncGenerator { const threadId = config?.configurable?.thread_id const checkpointNs = config?.configurable?.checkpoint_ns const beforeId = options?.before?.configurable?.checkpoint_id const results = await this.client.list({ threadId, checkpointNs, before: beforeId, limit: options?.limit, }) for (const result of results) { const tuple = this.responseToTuple(result) if (tuple) { yield tuple } } } /** * Store a checkpoint asynchronously. */ async put( config: RunnableConfig, checkpoint: Checkpoint, metadata: CheckpointMetadata, newVersions?: Record ): Promise { const threadId = this.getThreadId(config) const checkpointNs = this.getCheckpointNs(config) let checkpointId = this.getCheckpointId(config) if (!checkpointId) { checkpointId = checkpoint.id ?? this.generateCheckpointId() } const apiConfig: CheckpointConfig = { configurable: { thread_id: threadId, checkpoint_ns: checkpointNs, checkpoint_id: checkpointId, }, } const checkpointData = this.checkpointToApi(checkpoint) const metadataData = this.metadataToApi(metadata) const result = await this.client.put( apiConfig, checkpointData, metadataData, newVersions ?? {} ) return result.config as RunnableConfig } /** * Store intermediate writes asynchronously. */ async putWrites( _config: RunnableConfig, _writes: PendingWrite[], _taskId: string ): Promise { // Stub - writes are not persisted separately // They are included in the checkpoint metadata } /** * Delete all checkpoints for a thread. */ async deleteThread(threadId: string): Promise { await this.client.deleteThread(threadId) } /** * Convert checkpoint to API format. */ private checkpointToApi(checkpoint: Checkpoint): CheckpointData { return { v: checkpoint.v ?? 1, id: checkpoint.id ?? this.generateCheckpointId(), ts: checkpoint.ts ?? new Date().toISOString(), channel_values: this.serializeChannelValues(checkpoint.channel_values ?? {}), channel_versions: checkpoint.channel_versions ?? {}, versions_seen: checkpoint.versions_seen ?? {}, pending_sends: checkpoint.pending_sends ?? [], } } /** * Convert metadata to API format. */ private metadataToApi(metadata: CheckpointMetadata): APICheckpointMetadata { return { source: metadata.source ?? 'input', step: metadata.step ?? -1, writes: metadata.writes, parents: metadata.parents ?? {}, } } /** * Convert API response to CheckpointTuple. */ private responseToTuple(response: APICheckpointTuple): CheckpointTuple { return { config: response.config as RunnableConfig, checkpoint: this.deserializeCheckpoint(response.checkpoint), metadata: response.metadata, parent_config: response.parent_config as RunnableConfig | undefined, pending_writes: response.pending_writes ?? [], } } /** * Serialize channel values for transmission. */ private serializeChannelValues(values: Record): Record { return this.serializeValue(values) as Record } /** * Serialize a value for JSON transmission. */ private serializeValue(value: unknown, depth: number = 0): unknown { const MAX_DEPTH = 100 if (depth > MAX_DEPTH) { return { __max_depth_exceeded__: true } } if (value === null || value === undefined) { return value } // Handle Buffer/Uint8Array if (Buffer.isBuffer(value) || value instanceof Uint8Array) { return { __bytes__: Buffer.from(value).toString('base64') } } // Handle arrays if (Array.isArray(value)) { return value.map(item => this.serializeValue(item, depth + 1)) } // Handle objects if (typeof value === 'object') { const result: Record = {} for (const [key, val] of Object.entries(value)) { result[key] = this.serializeValue(val, depth + 1) } return result } return value } /** * Deserialize checkpoint data. */ private deserializeCheckpoint(data: CheckpointData): Checkpoint { return { ...data, channel_values: this.deserializeValue(data.channel_values) as Record, } } /** * Deserialize a value. */ private deserializeValue(value: unknown): unknown { if (value === null || value === undefined) { return value } if (typeof value === 'object' && !Array.isArray(value)) { const obj = value as Record // Decode base64 bytes if ('__bytes__' in obj && typeof obj.__bytes__ === 'string') { return Buffer.from(obj.__bytes__, 'base64') } // Recurse into object const result: Record = {} for (const [key, val] of Object.entries(obj)) { result[key] = this.deserializeValue(val) } return result } if (Array.isArray(value)) { return value.map(item => this.deserializeValue(item)) } return value } } export type { RunnableConfig, Checkpoint, CheckpointMetadata, CheckpointTuple, PendingWrite }