Skip to content

Commit 6ead962

Browse files
committed
feat: add LangGraph streaming adapter and message format converter
1 parent 4e1f9ad commit 6ead962

File tree

4 files changed

+168
-24
lines changed

4 files changed

+168
-24
lines changed

packages/react-headless/src/index.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ export {
99
openAIReadableStreamAdapter,
1010
openAIResponsesAdapter,
1111
} from "./stream/adapters";
12-
export { openAIConversationMessageFormat, openAIMessageFormat } from "./stream/formats";
12+
export {
13+
langGraphMessageFormat,
14+
openAIConversationMessageFormat,
15+
openAIMessageFormat,
16+
} from "./stream/formats";
1317
export { processStreamedMessage } from "./stream/processStreamedMessage";
1418

1519
export type {
@@ -39,8 +43,9 @@ export type {
3943
UserMessage,
4044
} from "./types/message";
4145

46+
export type { LangGraphAdapterOptions } from "./stream/adapters/langgraph";
47+
export type { LangGraphMessageFormat } from "./stream/formats/langgraph-message-format";
4248
export { identityMessageFormat } from "./types/messageFormat";
4349
export type { MessageFormat } from "./types/messageFormat";
4450
export { EventType } from "./types/stream";
4551
export type { AGUIEvent, StreamProtocolAdapter } from "./types/stream";
46-
export type { LangGraphAdapterOptions } from "./stream/adapters/langgraph";

packages/react-headless/src/stream/adapters/langgraph.ts

Lines changed: 6 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,7 @@ export interface LangGraphAdapterOptions {
6060
* />
6161
* ```
6262
*/
63-
export const langGraphAdapter = (
64-
options?: LangGraphAdapterOptions,
65-
): StreamProtocolAdapter => ({
63+
export const langGraphAdapter = (options?: LangGraphAdapterOptions): StreamProtocolAdapter => ({
6664
async *parse(response: Response): AsyncIterable<AGUIEvent> {
6765
const reader = response.body?.getReader();
6866
if (!reader) throw new Error("No response body");
@@ -116,16 +114,10 @@ export const langGraphAdapter = (
116114
| [LangGraphAIMessage, LangGraphMessageMetadata]
117115
| LangGraphAIMessage;
118116

119-
const msg: LangGraphAIMessage = Array.isArray(tuple)
120-
? tuple[0]
121-
: tuple;
117+
const msg: LangGraphAIMessage = Array.isArray(tuple) ? tuple[0] : tuple;
122118

123119
// Only handle AI messages
124-
if (
125-
msg.type !== "ai" &&
126-
msg.type !== "AIMessageChunk" &&
127-
msg.type !== "AIMessage"
128-
) {
120+
if (msg.type !== "ai" && msg.type !== "AIMessageChunk" && msg.type !== "AIMessage") {
129121
break;
130122
}
131123

@@ -193,10 +185,7 @@ export const langGraphAdapter = (
193185
toolCallName: tc.name,
194186
};
195187

196-
const argsStr =
197-
typeof tc.args === "string"
198-
? tc.args
199-
: JSON.stringify(tc.args);
188+
const argsStr = typeof tc.args === "string" ? tc.args : JSON.stringify(tc.args);
200189
yield {
201190
type: EventType.TOOL_CALL_ARGS,
202191
toolCallId,
@@ -218,10 +207,7 @@ export const langGraphAdapter = (
218207
// Payload: { [node_name]: node_output }
219208
// Check for interrupts
220209
const updates = parsed as Record<string, unknown>;
221-
if (
222-
"__interrupt__" in updates &&
223-
options?.onInterrupt
224-
) {
210+
if ("__interrupt__" in updates && options?.onInterrupt) {
225211
options.onInterrupt(updates["__interrupt__"]);
226212
}
227213
break;
@@ -310,9 +296,7 @@ function parseSSEBlock(block: string): { event: string; data: string } {
310296
* Extract text content from a LangGraph message content field.
311297
* Content can be a plain string or an array of typed content blocks.
312298
*/
313-
function extractTextContent(
314-
content: string | Array<{ type: string; text?: string }>,
315-
): string {
299+
function extractTextContent(content: string | Array<{ type: string; text?: string }>): string {
316300
if (typeof content === "string") return content;
317301

318302
return content
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1+
export * from "./langgraph-message-format";
12
export * from "./openai-conversation-message-format";
23
export * from "./openai-message-format";
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import type { AssistantMessage, Message, ToolMessage, UserMessage } from "../../types";
2+
import type { MessageFormat } from "../../types/messageFormat";
3+
4+
// ── LangGraph / LangChain message types ──────────────────────────
5+
6+
/**
7+
* LangChain-style message as returned by the LangGraph thread state API.
8+
* Each message carries a `type` discriminator (`"human"`, `"ai"`, `"tool"`,
9+
* `"system"`) and uses snake_case field names.
10+
*/
11+
interface LangChainMessage {
12+
id?: string;
13+
type: "human" | "ai" | "tool" | "system" | "developer" | (string & {});
14+
content: string | Array<{ type: string; text?: string }>;
15+
name?: string;
16+
tool_calls?: Array<{
17+
id: string;
18+
name: string;
19+
args: Record<string, unknown> | string;
20+
}>;
21+
tool_call_id?: string;
22+
}
23+
24+
// ── Outbound (AG-UI → LangGraph) ────────────────────────────────
25+
26+
function toLangChainMessage(message: Message): LangChainMessage {
27+
switch (message.role) {
28+
case "user":
29+
return { type: "human", content: message.content ?? "" };
30+
31+
case "assistant": {
32+
const result: LangChainMessage = { type: "ai", content: message.content ?? "" };
33+
if (message.toolCalls?.length) {
34+
result.tool_calls = message.toolCalls.map((tc) => ({
35+
id: tc.id,
36+
name: tc.function.name,
37+
args: safeParseArgs(tc.function.arguments),
38+
}));
39+
}
40+
return result;
41+
}
42+
43+
case "tool":
44+
return {
45+
type: "tool",
46+
content: message.content,
47+
tool_call_id: message.toolCallId,
48+
};
49+
50+
case "system":
51+
return { type: "system", content: message.content };
52+
53+
case "developer":
54+
return { type: "system", content: message.content };
55+
56+
default:
57+
return { type: "system", content: "" };
58+
}
59+
}
60+
61+
// ── Inbound (LangGraph → AG-UI) ────────────────────────────────
62+
63+
function fromLangChainMessage(msg: LangChainMessage): Message {
64+
const id = msg.id ?? crypto.randomUUID();
65+
66+
switch (msg.type) {
67+
case "human":
68+
return { id, role: "user", content: extractContent(msg.content) } satisfies UserMessage;
69+
70+
case "ai": {
71+
const result: AssistantMessage = {
72+
id,
73+
role: "assistant",
74+
content: extractContent(msg.content),
75+
};
76+
if (msg.tool_calls?.length) {
77+
result.toolCalls = msg.tool_calls.map((tc) => ({
78+
id: tc.id,
79+
type: "function" as const,
80+
function: {
81+
name: tc.name,
82+
arguments: typeof tc.args === "string" ? tc.args : JSON.stringify(tc.args),
83+
},
84+
}));
85+
}
86+
return result;
87+
}
88+
89+
case "tool":
90+
return {
91+
id,
92+
role: "tool",
93+
content: extractContent(msg.content),
94+
toolCallId: msg.tool_call_id ?? "",
95+
} satisfies ToolMessage;
96+
97+
case "system":
98+
case "developer":
99+
return { id, role: "system", content: extractContent(msg.content) };
100+
101+
default:
102+
return { id, role: "system", content: extractContent(msg.content) };
103+
}
104+
}
105+
106+
// ── Helpers ──────────────────────────────────────────────────────
107+
108+
function extractContent(content: string | Array<{ type: string; text?: string }>): string {
109+
if (typeof content === "string") return content;
110+
return content
111+
.filter((block) => block.type === "text" && block.text)
112+
.map((block) => block.text!)
113+
.join("");
114+
}
115+
116+
function safeParseArgs(args: string): Record<string, unknown> | string {
117+
try {
118+
return JSON.parse(args) as Record<string, unknown>;
119+
} catch {
120+
return args;
121+
}
122+
}
123+
124+
// ── MessageFormat implementation ─────────────────────────────────
125+
126+
/**
127+
* Converts between AG-UI message format and LangGraph's LangChain-style
128+
* message format.
129+
*
130+
* LangGraph uses `type` discriminators (`"human"`, `"ai"`, `"tool"`,
131+
* `"system"`) instead of `role`, and tool call arguments are objects
132+
* rather than JSON strings.
133+
*
134+
* AG-UI → LangGraph (toApi):
135+
* - Maps `role` to `type` (`"user"` → `"human"`, `"assistant"` → `"ai"`)
136+
* - Converts `toolCalls[].function.arguments` from JSON string to object
137+
* - Converts `toolCallId` → `tool_call_id`
138+
*
139+
* LangGraph → AG-UI (fromApi):
140+
* - Maps `type` to `role` (`"human"` → `"user"`, `"ai"` → `"assistant"`)
141+
* - Converts tool call `args` object to JSON string
142+
* - Generates `id` via `crypto.randomUUID()` if not present
143+
*/
144+
export const langGraphMessageFormat: MessageFormat = {
145+
toApi(messages: Message[]): LangChainMessage[] {
146+
return messages.map(toLangChainMessage);
147+
},
148+
149+
fromApi(data: unknown): Message[] {
150+
return (data as LangChainMessage[]).map(fromLangChainMessage);
151+
},
152+
};
153+
154+
export type { LangChainMessage as LangGraphMessageFormat };

0 commit comments

Comments
 (0)