diff --git a/packages/react-headless/src/index.ts b/packages/react-headless/src/index.ts index c37984d7c..868af6412 100644 --- a/packages/react-headless/src/index.ts +++ b/packages/react-headless/src/index.ts @@ -8,11 +8,16 @@ export { ArtifactContext, useArtifactStore } from "./store/ArtifactContext"; export { ChatProvider } from "./store/ChatProvider"; export { agUIAdapter, + langGraphAdapter, openAIAdapter, openAIReadableStreamAdapter, openAIResponsesAdapter, } from "./stream/adapters"; -export { openAIConversationMessageFormat, openAIMessageFormat } from "./stream/formats"; +export { + langGraphMessageFormat, + openAIConversationMessageFormat, + openAIMessageFormat, +} from "./stream/formats"; export { processStreamedMessage } from "./stream/processStreamedMessage"; export type { ArtifactActions, ArtifactState } from "./store/artifactTypes"; @@ -44,6 +49,8 @@ export type { UserMessage, } from "./types/message"; +export type { LangGraphAdapterOptions } from "./stream/adapters/langgraph"; +export type { LangGraphMessageFormat } from "./stream/formats/langgraph-message-format"; export { identityMessageFormat } from "./types/messageFormat"; export type { MessageFormat } from "./types/messageFormat"; export { EventType } from "./types/stream"; diff --git a/packages/react-headless/src/stream/adapters/__tests__/langgraph.test.ts b/packages/react-headless/src/stream/adapters/__tests__/langgraph.test.ts new file mode 100644 index 000000000..ac142d338 --- /dev/null +++ b/packages/react-headless/src/stream/adapters/__tests__/langgraph.test.ts @@ -0,0 +1,348 @@ +import { describe, expect, it, vi } from "vitest"; +import { EventType } from "../../../types"; +import { langGraphAdapter } from "../langgraph"; + +// ── Helpers ── + +/** + * Create a Response with an SSE body from a raw string. + */ +function makeSSEResponse(body: string): Response { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(body)); + controller.close(); + }, + }); + return new Response(stream); +} + +/** + * Build a named SSE block: `event: \ndata: \n\n` + */ +function sse(event: string, data: unknown): string { + return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; +} + +/** + * Collect all events from an async iterable. + */ +async function collect(iter: AsyncIterable): Promise { + const events: unknown[] = []; + for await (const event of iter) { + events.push(event); + } + return events; +} + +// ── Tests ── + +describe("langGraphAdapter", () => { + it("throws when response has no body", async () => { + const adapter = langGraphAdapter(); + const response = new Response(null); + + await expect(async () => { + for await (const _ of adapter.parse(response)) { + /* drain */ + } + }).rejects.toThrow("No response body"); + }); + + describe("text streaming", () => { + it("emits TEXT_MESSAGE_START, CONTENT, and END for AI message chunks", async () => { + const body = + sse("messages", [ + { type: "AIMessageChunk", content: "Hello", id: "msg-1" }, + { langgraph_node: "agent" }, + ]) + + sse("messages", [ + { type: "AIMessageChunk", content: " world", id: "msg-1" }, + { langgraph_node: "agent" }, + ]) + + sse("end", null); + + const adapter = langGraphAdapter(); + const events = await collect(adapter.parse(makeSSEResponse(body))); + + expect(events[0]).toMatchObject({ + type: EventType.TEXT_MESSAGE_START, + role: "assistant", + }); + expect(events[1]).toMatchObject({ + type: EventType.TEXT_MESSAGE_CONTENT, + delta: "Hello", + }); + expect(events[2]).toMatchObject({ + type: EventType.TEXT_MESSAGE_CONTENT, + delta: " world", + }); + expect(events[3]).toMatchObject({ + type: EventType.TEXT_MESSAGE_END, + }); + }); + + it("handles content as array of typed blocks", async () => { + const body = + sse("messages", [ + { + type: "AIMessageChunk", + content: [ + { type: "text", text: "block one" }, + { type: "text", text: " block two" }, + ], + id: "msg-1", + }, + { langgraph_node: "agent" }, + ]) + sse("end", null); + + const adapter = langGraphAdapter(); + const events = await collect(adapter.parse(makeSSEResponse(body))); + + expect(events[1]).toMatchObject({ + type: EventType.TEXT_MESSAGE_CONTENT, + delta: "block one block two", + }); + }); + + it("handles non-tuple message format (plain object)", async () => { + const body = + sse("messages", { type: "ai", content: "plain", id: "msg-1" }) + sse("end", null); + + const adapter = langGraphAdapter(); + const events = await collect(adapter.parse(makeSSEResponse(body))); + + expect(events[0]).toMatchObject({ type: EventType.TEXT_MESSAGE_START }); + expect(events[1]).toMatchObject({ + type: EventType.TEXT_MESSAGE_CONTENT, + delta: "plain", + }); + }); + + it("ignores non-AI message types", async () => { + const body = + sse("messages", [ + { type: "human", content: "user input", id: "hmsg-1" }, + { langgraph_node: "agent" }, + ]) + sse("end", null); + + const adapter = langGraphAdapter(); + const events = await collect(adapter.parse(makeSSEResponse(body))); + + // Should only have the end event (no message start/content) + expect(events).toHaveLength(0); + }); + }); + + describe("tool calls", () => { + it("emits tool call events for tool_call_chunks", async () => { + const body = + sse("messages", [ + { + type: "AIMessageChunk", + content: "", + tool_call_chunks: [{ id: "tc-1", name: "get_weather", args: '{"loc', index: 0 }], + }, + { langgraph_node: "agent" }, + ]) + + sse("messages", [ + { + type: "AIMessageChunk", + content: "", + tool_call_chunks: [{ id: undefined, name: undefined, args: 'ation":"NYC"}', index: 0 }], + }, + { langgraph_node: "agent" }, + ]) + + sse("end", null); + + const adapter = langGraphAdapter(); + const events = await collect(adapter.parse(makeSSEResponse(body))); + + // TEXT_MESSAGE_START, TOOL_CALL_START, TOOL_CALL_ARGS, TOOL_CALL_ARGS, TOOL_CALL_END, TEXT_MESSAGE_END + const toolStart = events.find((e: any) => e.type === EventType.TOOL_CALL_START); + expect(toolStart).toMatchObject({ + type: EventType.TOOL_CALL_START, + toolCallId: "tc-1", + toolCallName: "get_weather", + }); + + const toolArgs = events.filter((e: any) => e.type === EventType.TOOL_CALL_ARGS); + expect(toolArgs).toHaveLength(2); + expect((toolArgs[0] as any).delta).toBe('{"loc'); + expect((toolArgs[1] as any).delta).toBe('ation":"NYC"}'); + }); + + it("emits tool call events for complete tool_calls (non-streaming)", async () => { + const body = + sse("messages", [ + { + type: "AIMessageChunk", + content: "", + tool_calls: [{ id: "tc-1", name: "search", args: { query: "test" } }], + }, + { langgraph_node: "agent" }, + ]) + sse("end", null); + + const adapter = langGraphAdapter(); + const events = await collect(adapter.parse(makeSSEResponse(body))); + + const toolStart = events.find((e: any) => e.type === EventType.TOOL_CALL_START); + expect(toolStart).toMatchObject({ + toolCallId: "tc-1", + toolCallName: "search", + }); + + const toolArgs = events.find((e: any) => e.type === EventType.TOOL_CALL_ARGS); + expect((toolArgs as any).delta).toBe('{"query":"test"}'); + + const toolEnd = events.filter((e: any) => e.type === EventType.TOOL_CALL_END); + // One from the complete tool_calls handling + one from the "end" event cleanup + expect(toolEnd.length).toBeGreaterThanOrEqual(1); + }); + }); + + describe("error handling", () => { + it("emits RUN_ERROR for error events", async () => { + const body = sse("error", { + error: "InternalError", + message: "Something went wrong", + }); + + const adapter = langGraphAdapter(); + const events = await collect(adapter.parse(makeSSEResponse(body))); + + expect(events[0]).toMatchObject({ + type: EventType.RUN_ERROR, + message: "Something went wrong", + code: "InternalError", + }); + }); + + it("handles malformed JSON gracefully", async () => { + const body = "event: messages\ndata: {invalid json}\n\n" + sse("end", null); + + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const adapter = langGraphAdapter(); + const events = await collect(adapter.parse(makeSSEResponse(body))); + + expect(consoleSpy).toHaveBeenCalledWith( + "Failed to parse LangGraph SSE data", + expect.any(SyntaxError), + ); + consoleSpy.mockRestore(); + + // Should still have processed remaining events (no events since "end" + // with no message started yields nothing) + expect(events).toHaveLength(0); + }); + }); + + describe("interrupts", () => { + it("calls onInterrupt when updates contain __interrupt__", async () => { + const onInterrupt = vi.fn(); + const body = + sse("messages", [ + { type: "AIMessageChunk", content: "thinking...", id: "msg-1" }, + { langgraph_node: "agent" }, + ]) + + sse("updates", { + agent: { messages: [] }, + __interrupt__: { value: "need input", resumable: true }, + }) + + sse("end", null); + + const adapter = langGraphAdapter({ onInterrupt }); + await collect(adapter.parse(makeSSEResponse(body))); + + expect(onInterrupt).toHaveBeenCalledWith({ + value: "need input", + resumable: true, + }); + }); + + it("does not throw when onInterrupt is not provided", async () => { + const body = + sse("updates", { + __interrupt__: { value: "need input" }, + }) + sse("end", null); + + const adapter = langGraphAdapter(); + // Should not throw + await collect(adapter.parse(makeSSEResponse(body))); + }); + }); + + describe("metadata event", () => { + it("does not emit events for metadata", async () => { + const body = + sse("metadata", { run_id: "run-123", thread_id: "thread-456" }) + sse("end", null); + + const adapter = langGraphAdapter(); + const events = await collect(adapter.parse(makeSSEResponse(body))); + + // No events should be emitted for metadata alone + expect(events).toHaveLength(0); + }); + }); + + describe("stream end", () => { + it("closes message on explicit end event", async () => { + const body = + sse("messages", [ + { type: "AIMessageChunk", content: "done", id: "msg-1" }, + { langgraph_node: "agent" }, + ]) + sse("end", null); + + const adapter = langGraphAdapter(); + const events = await collect(adapter.parse(makeSSEResponse(body))); + + const lastEvent = events[events.length - 1] as any; + expect(lastEvent.type).toBe(EventType.TEXT_MESSAGE_END); + }); + + it("closes message when stream ends without end event", async () => { + const body = sse("messages", [ + { type: "AIMessageChunk", content: "abrupt", id: "msg-1" }, + { langgraph_node: "agent" }, + ]); + + const adapter = langGraphAdapter(); + const events = await collect(adapter.parse(makeSSEResponse(body))); + + const lastEvent = events[events.length - 1] as any; + expect(lastEvent.type).toBe(EventType.TEXT_MESSAGE_END); + }); + }); + + describe("multi-chunk delivery", () => { + it("handles SSE blocks split across multiple chunks", async () => { + // Simulate a response where SSE data arrives in two chunks, + // with the split happening mid-block. + const part1 = "event: messages\ndata: "; + const part2 = + JSON.stringify([ + { type: "AIMessageChunk", content: "split", id: "msg-1" }, + { langgraph_node: "agent" }, + ]) + + "\n\n" + + sse("end", null); + + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(part1)); + controller.enqueue(new TextEncoder().encode(part2)); + controller.close(); + }, + }); + + const adapter = langGraphAdapter(); + const events = await collect(adapter.parse(new Response(stream))); + + expect(events[0]).toMatchObject({ type: EventType.TEXT_MESSAGE_START }); + expect(events[1]).toMatchObject({ + type: EventType.TEXT_MESSAGE_CONTENT, + delta: "split", + }); + }); + }); +}); diff --git a/packages/react-headless/src/stream/adapters/index.ts b/packages/react-headless/src/stream/adapters/index.ts index a3e2e4d74..9bfda1a25 100644 --- a/packages/react-headless/src/stream/adapters/index.ts +++ b/packages/react-headless/src/stream/adapters/index.ts @@ -1,4 +1,5 @@ export * from "./ag-ui"; +export * from "./langgraph"; export * from "./openai-completions"; export * from "./openai-readable-stream"; export * from "./openai-responses"; diff --git a/packages/react-headless/src/stream/adapters/langgraph.ts b/packages/react-headless/src/stream/adapters/langgraph.ts new file mode 100644 index 000000000..8cc58fd17 --- /dev/null +++ b/packages/react-headless/src/stream/adapters/langgraph.ts @@ -0,0 +1,306 @@ +import { AGUIEvent, EventType, StreamProtocolAdapter } from "../../types"; + +/** + * Represents a LangGraph AI message (or chunk) as received in the + * `messages` stream mode. + */ +interface LangGraphAIMessage { + id?: string; + type: "ai" | "AIMessageChunk" | "AIMessage"; + content: string | Array<{ type: string; text?: string }>; + tool_calls?: Array<{ + id: string; + name: string; + args: Record; + }>; + tool_call_chunks?: Array<{ + id?: string; + name?: string; + args?: string; + index?: number; + }>; +} + +/** + * Metadata attached to each message tuple in the `messages` stream mode. + */ +interface LangGraphMessageMetadata { + langgraph_node?: string; + langgraph_step?: number; + langgraph_triggers?: string[]; + langgraph_checkpoint_ns?: string; + tags?: string[]; + ls_model_name?: string; +} + +/** + * Options for the LangGraph adapter. + */ +export interface LangGraphAdapterOptions { + /** + * Called when a LangGraph interrupt is encountered in an `updates` event. + * The interrupt payload is the value of the `__interrupt__` key. + */ + onInterrupt?: (interrupt: unknown) => void; +} + +/** + * Adapter for LangGraph streaming responses. + * + * LangGraph uses named SSE events (`event: \ndata: \n\n`) + * rather than the `data:`-only format used by OpenAI. The adapter handles + * the `messages`, `metadata`, `updates`, and `error` event types and maps + * them to AG-UI events. + * + * Usage: + * ```tsx + * + * ``` + */ +export const langGraphAdapter = (options?: LangGraphAdapterOptions): StreamProtocolAdapter => ({ + async *parse(response: Response): AsyncIterable { + const reader = response.body?.getReader(); + if (!reader) throw new Error("No response body"); + + const decoder = new TextDecoder(); + const messageId = crypto.randomUUID(); + const toolCallIds: Record = {}; + let messageStarted = false; + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + // SSE events are separated by double newlines. + // Split on \n\n to get complete event blocks, keeping the last + // (possibly incomplete) block in the buffer. + const blocks = buffer.split("\n\n"); + buffer = blocks.pop() ?? ""; + + for (const block of blocks) { + const trimmed = block.trim(); + if (!trimmed) continue; + + const { event, data } = parseSSEBlock(trimmed); + if (!data) continue; + + let parsed: unknown; + try { + parsed = JSON.parse(data); + } catch (e) { + console.error("Failed to parse LangGraph SSE data", e); + continue; + } + + switch (event) { + case "metadata": { + // Metadata event signals the run has started. + // Payload: { run_id: string, thread_id: string } + // We don't emit RUN_STARTED because processStreamedMessage + // doesn't handle it — it's informational only. + break; + } + + case "messages": { + // Payload is a tuple: [message_chunk, metadata] + // or just a message object depending on the stream version. + const tuple = parsed as + | [LangGraphAIMessage, LangGraphMessageMetadata] + | LangGraphAIMessage; + + const msg: LangGraphAIMessage = Array.isArray(tuple) ? tuple[0] : tuple; + + // Only handle AI messages + if (msg.type !== "ai" && msg.type !== "AIMessageChunk" && msg.type !== "AIMessage") { + break; + } + + // Emit TEXT_MESSAGE_START on first AI message chunk + if (!messageStarted) { + yield { + type: EventType.TEXT_MESSAGE_START, + messageId, + role: "assistant", + }; + messageStarted = true; + } + + // Handle text content + const textContent = extractTextContent(msg.content); + if (textContent) { + yield { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId, + delta: textContent, + }; + } + + // Handle streaming tool call chunks (partial arguments) + if (msg.tool_call_chunks) { + for (const chunk of msg.tool_call_chunks) { + const index = chunk.index ?? 0; + + if (chunk.id && !toolCallIds[index]) { + toolCallIds[index] = chunk.id; + yield { + type: EventType.TOOL_CALL_START, + toolCallId: chunk.id, + toolCallName: chunk.name || "", + }; + } + + if (chunk.args) { + const toolCallId = toolCallIds[index]; + if (toolCallId) { + yield { + type: EventType.TOOL_CALL_ARGS, + toolCallId, + delta: chunk.args, + }; + } + } + } + } + + // Handle complete tool calls (non-streaming, full args at once) + if (msg.tool_calls && msg.tool_calls.length > 0) { + for (let i = 0; i < msg.tool_calls.length; i++) { + const tc = msg.tool_calls[i]; + if (!tc) continue; + + const toolCallId = tc.id || crypto.randomUUID(); + + // Only emit if we haven't already started this tool call + // via tool_call_chunks + if (!Object.values(toolCallIds).includes(toolCallId)) { + yield { + type: EventType.TOOL_CALL_START, + toolCallId, + toolCallName: tc.name, + }; + + const argsStr = typeof tc.args === "string" ? tc.args : JSON.stringify(tc.args); + yield { + type: EventType.TOOL_CALL_ARGS, + toolCallId, + delta: argsStr, + }; + + yield { + type: EventType.TOOL_CALL_END, + toolCallId, + }; + } + } + } + + break; + } + + case "updates": { + // Payload: { [node_name]: node_output } + // Check for interrupts + const updates = parsed as Record; + if ("__interrupt__" in updates && options?.onInterrupt) { + options.onInterrupt(updates["__interrupt__"]); + } + break; + } + + case "error": { + // Payload: { error: string, message: string } + const err = parsed as { error?: string; message?: string }; + yield { + type: EventType.RUN_ERROR, + message: err.message || err.error || "Unknown error", + code: err.error ?? undefined, + }; + break; + } + + case "end": { + // Stream has ended — close out any open message/tool calls. + if (messageStarted) { + // End any open streaming tool calls + for (const toolCallId of Object.values(toolCallIds)) { + yield { + type: EventType.TOOL_CALL_END, + toolCallId, + }; + } + + yield { + type: EventType.TEXT_MESSAGE_END, + messageId, + }; + messageStarted = false; // Prevent duplicate end in fallback + } + break; + } + + // Intentionally unhandled: values, debug, tasks, checkpoints, custom + default: + break; + } + } + } + + // If stream ended without an explicit "end" event, close out. + if (messageStarted) { + for (const toolCallId of Object.values(toolCallIds)) { + yield { + type: EventType.TOOL_CALL_END, + toolCallId, + }; + } + yield { + type: EventType.TEXT_MESSAGE_END, + messageId, + }; + } + }, +}); + +/** + * Parse an SSE block into its event name and data payload. + * + * LangGraph SSE format: + * ``` + * event: messages + * data: [{"content": "Hello", ...}, {"langgraph_node": "agent"}] + * ``` + */ +function parseSSEBlock(block: string): { event: string; data: string } { + let event = ""; + const dataLines: string[] = []; + + for (const line of block.split("\n")) { + if (line.startsWith("event:")) { + event = line.slice(6).trim(); + } else if (line.startsWith("data:")) { + dataLines.push(line.slice(5).trim()); + } + // Ignore id:, retry:, and comment lines + } + + return { event, data: dataLines.join("\n") }; +} + +/** + * Extract text content from a LangGraph message content field. + * Content can be a plain string or an array of typed content blocks. + */ +function extractTextContent(content: string | Array<{ type: string; text?: string }>): string { + if (typeof content === "string") return content; + + return content + .filter((block) => block.type === "text" && block.text) + .map((block) => block.text!) + .join(""); +} diff --git a/packages/react-headless/src/stream/formats/index.ts b/packages/react-headless/src/stream/formats/index.ts index ed4cdd377..1d627b7b6 100644 --- a/packages/react-headless/src/stream/formats/index.ts +++ b/packages/react-headless/src/stream/formats/index.ts @@ -1,2 +1,3 @@ +export * from "./langgraph-message-format"; export * from "./openai-conversation-message-format"; export * from "./openai-message-format"; diff --git a/packages/react-headless/src/stream/formats/langgraph-message-format.ts b/packages/react-headless/src/stream/formats/langgraph-message-format.ts new file mode 100644 index 000000000..7bb85fc92 --- /dev/null +++ b/packages/react-headless/src/stream/formats/langgraph-message-format.ts @@ -0,0 +1,154 @@ +import type { AssistantMessage, Message, ToolMessage, UserMessage } from "../../types"; +import type { MessageFormat } from "../../types/messageFormat"; + +// ── LangGraph / LangChain message types ────────────────────────── + +/** + * LangChain-style message as returned by the LangGraph thread state API. + * Each message carries a `type` discriminator (`"human"`, `"ai"`, `"tool"`, + * `"system"`) and uses snake_case field names. + */ +interface LangChainMessage { + id?: string; + type: "human" | "ai" | "tool" | "system" | "developer" | (string & {}); + content: string | Array<{ type: string; text?: string }>; + name?: string; + tool_calls?: Array<{ + id: string; + name: string; + args: Record | string; + }>; + tool_call_id?: string; +} + +// ── Outbound (AG-UI → LangGraph) ──────────────────────────────── + +function toLangChainMessage(message: Message): LangChainMessage { + switch (message.role) { + case "user": + return { type: "human", content: message.content ?? "" }; + + case "assistant": { + const result: LangChainMessage = { type: "ai", content: message.content ?? "" }; + if (message.toolCalls?.length) { + result.tool_calls = message.toolCalls.map((tc) => ({ + id: tc.id, + name: tc.function.name, + args: safeParseArgs(tc.function.arguments), + })); + } + return result; + } + + case "tool": + return { + type: "tool", + content: message.content, + tool_call_id: message.toolCallId, + }; + + case "system": + return { type: "system", content: message.content }; + + case "developer": + return { type: "system", content: message.content }; + + default: + return { type: "system", content: "" }; + } +} + +// ── Inbound (LangGraph → AG-UI) ──────────────────────────────── + +function fromLangChainMessage(msg: LangChainMessage): Message { + const id = msg.id ?? crypto.randomUUID(); + + switch (msg.type) { + case "human": + return { id, role: "user", content: extractContent(msg.content) } satisfies UserMessage; + + case "ai": { + const result: AssistantMessage = { + id, + role: "assistant", + content: extractContent(msg.content), + }; + if (msg.tool_calls?.length) { + result.toolCalls = msg.tool_calls.map((tc) => ({ + id: tc.id, + type: "function" as const, + function: { + name: tc.name, + arguments: typeof tc.args === "string" ? tc.args : JSON.stringify(tc.args), + }, + })); + } + return result; + } + + case "tool": + return { + id, + role: "tool", + content: extractContent(msg.content), + toolCallId: msg.tool_call_id ?? "", + } satisfies ToolMessage; + + case "system": + case "developer": + return { id, role: "system", content: extractContent(msg.content) }; + + default: + return { id, role: "system", content: extractContent(msg.content) }; + } +} + +// ── Helpers ────────────────────────────────────────────────────── + +function extractContent(content: string | Array<{ type: string; text?: string }>): string { + if (typeof content === "string") return content; + return content + .filter((block) => block.type === "text" && block.text) + .map((block) => block.text!) + .join(""); +} + +function safeParseArgs(args: string): Record | string { + try { + return JSON.parse(args) as Record; + } catch { + return args; + } +} + +// ── MessageFormat implementation ───────────────────────────────── + +/** + * Converts between AG-UI message format and LangGraph's LangChain-style + * message format. + * + * LangGraph uses `type` discriminators (`"human"`, `"ai"`, `"tool"`, + * `"system"`) instead of `role`, and tool call arguments are objects + * rather than JSON strings. + * + * AG-UI → LangGraph (toApi): + * - Maps `role` to `type` (`"user"` → `"human"`, `"assistant"` → `"ai"`) + * - Converts `toolCalls[].function.arguments` from JSON string to object + * - Converts `toolCallId` → `tool_call_id` + * + * LangGraph → AG-UI (fromApi): + * - Maps `type` to `role` (`"human"` → `"user"`, `"ai"` → `"assistant"`) + * - Converts tool call `args` object to JSON string + * - Generates `id` via `crypto.randomUUID()` if not present + */ +export const langGraphMessageFormat: MessageFormat = { + toApi(messages: Message[]): LangChainMessage[] { + return messages.map(toLangChainMessage); + }, + + fromApi(data: unknown): Message[] { + return (data as LangChainMessage[]).map(fromLangChainMessage); + }, +}; + +export type { LangChainMessage as LangGraphMessageFormat };