Skip to content

Commit 96bd340

Browse files
committed
fix(showcase/langgraph-typescript): fix beautiful-chat A2UI format + agentId + declarative-gen-ui tool naming
1 parent a8388dc commit 96bd340

3 files changed

Lines changed: 303 additions & 86 deletions

File tree

showcase/integrations/langgraph-typescript/src/agent/a2ui-dynamic.ts

Lines changed: 225 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,39 @@
55
*
66
* Pattern:
77
* - The agent binds an explicit `generate_a2ui` tool. When called, `generate_a2ui`
8-
* invokes a secondary LLM bound to `render_a2ui` (tool_choice forced) using the
9-
* registered client catalog injected as `copilotkit.context`.
8+
* invokes a secondary LLM bound to `_design_a2ui_surface` (tool_choice forced)
9+
* using the registered client catalog injected as `copilotkit.context`.
10+
* The internal tool is intentionally NOT named `render_a2ui` because the A2UI
11+
* middleware default-intercepts tool calls by that name from the run's event
12+
* stream and synthesises ACTIVITY_SNAPSHOT events from the LLM's RAW streaming
13+
* args (catalogId + components, before our code can validate). That bypass
14+
* surfaces "Cannot create component root without a type" infinite-loops.
15+
* Renaming sidesteps the middleware's intercept list (`a2uiToolNames`).
1016
* - The tool result returns an `a2ui_operations` container which the A2UI
1117
* middleware detects in the tool-call result and forwards to the frontend
1218
* renderer.
1319
* - The runtime (see `src/app/api/copilotkit-declarative-gen-ui/route.ts`) uses
1420
* `injectA2UITool: false` because the tool binding is owned by the agent here.
21+
*
22+
* State access for `generate_a2ui`:
23+
* The tool needs `state.messages` and `state.copilotkit.context` to forward
24+
* conversation history and the A2UI catalog schema to the secondary LLM. The
25+
* built-in `ToolNode` does not thread graph state through to tool execution
26+
* config. In Python, `ToolRuntime` provides this automatically. In TypeScript
27+
* we solve it with a state-aware wrapper node that snapshots state into a
28+
* module-level variable before delegating to `ToolNode`. This preserves
29+
* the standard LangChain tool invocation path (critical for `OnToolEnd`
30+
* events that the AG-UI adapter converts to `TOOL_CALL_RESULT` — which the
31+
* A2UI middleware needs to detect `a2ui_operations` in the tool result).
1532
*/
1633

1734
import { z } from "zod";
18-
import { RunnableConfig } from "@langchain/core/runnables";
1935
import { tool } from "@langchain/core/tools";
36+
import {
37+
AIMessage,
38+
SystemMessage,
39+
} from "@langchain/core/messages";
2040
import { ToolNode } from "@langchain/langgraph/prebuilt";
21-
import { AIMessage, SystemMessage } from "@langchain/core/messages";
2241
import {
2342
MemorySaver,
2443
START,
@@ -52,6 +71,47 @@ const SYSTEM_PROMPT =
5271
"automatically. Keep chat replies to one short sentence; let the UI do " +
5372
"the talking.";
5473

74+
// Matches Python's _GENERATE_A2UI_PROMPT_HEADER — instructs the secondary LLM
75+
// on flat component array format, required fields, and catalog constraints.
76+
const GENERATE_A2UI_PROMPT_HEADER =
77+
`You are designing a dynamic A2UI v0.9 surface. Call the \`_design_a2ui_surface\`\n` +
78+
`tool with a flat component array.\n\n` +
79+
`Hard requirements (failing any of these breaks the renderer — be strict):\n` +
80+
`- \`catalogId\` MUST be exactly: "${CUSTOM_CATALOG_ID}"\n` +
81+
`- \`surfaceId\` is a short kebab-case identifier (e.g. "kpi-dashboard").\n` +
82+
`- \`components\` is a FLAT array. Every entry MUST include both an \`id\` (unique\n` +
83+
` string) AND a \`component\` (string — the catalog component name). The root\n` +
84+
` entry MUST have \`id: "root"\` AND a valid \`component\` field — never emit\n` +
85+
` a root entry without a component type.\n` +
86+
`- Container components (Row, Column, Card) reference children by id via their\n` +
87+
` \`children\` (array of strings) or \`child\` (single string) prop. Do NOT inline\n` +
88+
` children objects. Define each child as its own entry in the flat array and\n` +
89+
` reference its id.\n` +
90+
`- Use only catalog component names listed in the schema below.`;
91+
92+
/**
93+
* Drop component entries that aren't objects or are missing `id`/`component`.
94+
* Mirrors Python's `sanitize_a2ui_components`.
95+
*/
96+
function sanitizeA2uiComponents(
97+
raw: unknown[],
98+
): Array<Record<string, unknown>> {
99+
return (raw ?? []).filter(
100+
(c): c is Record<string, unknown> =>
101+
typeof c === "object" &&
102+
c !== null &&
103+
typeof (c as Record<string, unknown>).id === "string" &&
104+
(c as Record<string, unknown>).id !== "" &&
105+
typeof (c as Record<string, unknown>).component === "string" &&
106+
(c as Record<string, unknown>).component !== "",
107+
) as Array<Record<string, unknown>>;
108+
}
109+
110+
/** True iff `components` contains an entry with `id === "root"`. */
111+
function hasRootComponent(components: Array<Record<string, unknown>>): boolean {
112+
return components.some((c) => c.id === "root");
113+
}
114+
55115
const AgentStateAnnotation = Annotation.Root({
56116
...CopilotKitStateAnnotation.spec,
57117
});
@@ -90,20 +150,36 @@ function renderA2uiOperations(operations: unknown[]): string {
90150
return JSON.stringify({ [A2UI_OPERATIONS_KEY]: operations });
91151
}
92152

93-
const generateA2ui = tool(
94-
async (_args, config?: RunnableConfig) => {
95-
// The runtime provided by the A2UI middleware injects a `copilotkit.context`
96-
// containing the registered client catalog schema + usage guidelines.
97-
const configurable = (config?.configurable ?? {}) as Record<
98-
string,
99-
unknown
100-
>;
101-
const state = (configurable.state ?? {}) as Record<string, unknown>;
102-
const messages = (state.messages ?? []) as unknown[];
103-
const copilotkit = (state.copilotkit ?? {}) as Record<string, unknown>;
104-
const contextEntries = (copilotkit.context ?? []) as Array<
105-
Record<string, unknown>
106-
>;
153+
// ---------------------------------------------------------------------------
154+
// State snapshot for tool access
155+
// ---------------------------------------------------------------------------
156+
// The built-in ToolNode does not forward graph state through config to tools.
157+
// Python's ToolRuntime provides `runtime.state` automatically; in TypeScript
158+
// we snapshot the state into a module-level variable before ToolNode runs.
159+
// This is safe because LangGraph TS runs graph nodes sequentially within a
160+
// single thread — no concurrent writes to this variable for the same run.
161+
let _currentState: AgentState | null = null;
162+
163+
/**
164+
* `generate_a2ui` — real LangChain tool invoked by ToolNode.
165+
*
166+
* Reads state from the module-level `_currentState` snapshot (set by the
167+
* `stateAwareToolNode` wrapper before ToolNode executes). Returns an
168+
* `a2ui_operations` JSON string that the A2UI middleware detects in the
169+
* `TOOL_CALL_RESULT` AG-UI event (emitted via the standard OnToolEnd path).
170+
*/
171+
const generateA2uiTool = tool(
172+
async () => {
173+
const state = _currentState;
174+
if (!state) {
175+
return JSON.stringify({ error: "No state available for generate_a2ui" });
176+
}
177+
178+
const messages = state.messages ?? [];
179+
const copilotkit = state.copilotkit ?? {};
180+
const contextEntries = (
181+
(copilotkit as Record<string, unknown>).context ?? []
182+
) as Array<Record<string, unknown>>;
107183

108184
const contextText = contextEntries
109185
.map((entry) =>
@@ -114,17 +190,22 @@ const generateA2ui = tool(
114190
.filter(Boolean)
115191
.join("\n\n");
116192

117-
const renderTool = tool(async () => "rendered", {
118-
name: "render_a2ui",
119-
description: "Render a dynamic A2UI v0.9 surface.",
193+
// Internal tool intentionally named `_design_a2ui_surface` (NOT
194+
// `render_a2ui`) to avoid the A2UI middleware's default tool-call
195+
// intercept. See module docstring.
196+
const designTool = tool(async () => "designed", {
197+
name: "_design_a2ui_surface",
198+
description: "Design a dynamic A2UI v0.9 surface.",
120199
schema: z.object({
121200
surfaceId: z.string().describe("Unique surface identifier."),
122201
catalogId: z
123202
.string()
124203
.describe(`The catalog ID (use "${CUSTOM_CATALOG_ID}").`),
125204
components: z
126205
.array(z.record(z.unknown()))
127-
.describe("A2UI v0.9 component array."),
206+
.describe(
207+
"A2UI v0.9 component array (flat format). Every entry MUST have `id` and `component`.",
208+
),
128209
data: z
129210
.record(z.unknown())
130211
.optional()
@@ -133,29 +214,94 @@ const generateA2ui = tool(
133214
});
134215

135216
const model = new ChatOpenAI({ temperature: 0, model: "gpt-4.1" });
136-
const modelWithTool = model.bindTools!([renderTool], {
137-
tool_choice: { type: "function", function: { name: "render_a2ui" } },
217+
const modelWithTool = model.bindTools!([designTool], {
218+
tool_choice: {
219+
type: "function",
220+
function: { name: "_design_a2ui_surface" },
221+
},
138222
});
139223

140-
// Drop the last message (the tool-call trigger itself) to mirror Python's
141-
// `runtime.state["messages"][:-1]`.
142-
const priorMessages = messages.slice(0, -1) as any[];
224+
// Prepend the explicit instruction header (matching Python's
225+
// _GENERATE_A2UI_PROMPT_HEADER) so the LLM knows about flat-array
226+
// constraints, required fields, and the canonical catalog ID.
227+
const prompt = `${GENERATE_A2UI_PROMPT_HEADER}\n\n${contextText}`.trim();
228+
229+
// Drop the last message (the tool-call trigger itself) to mirror
230+
// Python's `runtime.state["messages"][:-1]`.
231+
const rawPrior = (messages as unknown[]).slice(0, -1) as Array<{
232+
_getType?: () => string;
233+
type?: string;
234+
content?: unknown;
235+
tool_calls?: unknown[];
236+
additional_kwargs?: Record<string, unknown>;
237+
}>;
238+
239+
// Filter out ToolMessages and AIMessages that are pure tool_call
240+
// containers (no text content). The secondary LLM only needs
241+
// conversational context (human messages + AI text responses);
242+
// sending the graph's internal tool_call/tool_result pairs causes
243+
// OpenAI to reject with "tool_calls must be followed by tool
244+
// messages responding to each tool_call_id".
245+
const priorMessages = rawPrior.filter((msg) => {
246+
const msgType =
247+
typeof msg._getType === "function" ? msg._getType() : msg.type;
248+
if (msgType === "tool") return false;
249+
if (msgType === "ai") {
250+
const hasToolCalls =
251+
(msg.tool_calls as unknown[] | undefined)?.length
252+
? (msg.tool_calls as unknown[]).length > 0
253+
: false;
254+
const addlToolCalls = (
255+
msg.additional_kwargs?.tool_calls as unknown[] | undefined
256+
);
257+
const hasAddlToolCalls = addlToolCalls
258+
? addlToolCalls.length > 0
259+
: false;
260+
const hasContent =
261+
typeof msg.content === "string" &&
262+
(msg.content as string).trim().length > 0;
263+
if ((hasToolCalls || hasAddlToolCalls) && !hasContent) return false;
264+
}
265+
return true;
266+
});
143267

144-
const response = (await modelWithTool.invoke([
145-
new SystemMessage({ content: contextText }),
146-
...priorMessages,
147-
])) as AIMessage;
268+
let response: AIMessage;
269+
try {
270+
response = (await modelWithTool.invoke([
271+
new SystemMessage({ content: prompt }),
272+
...priorMessages,
273+
])) as AIMessage;
274+
} catch (err) {
275+
console.error("[a2ui-dynamic] Secondary LLM failed:", err);
276+
return JSON.stringify({ error: `Secondary LLM failed: ${err}` });
277+
}
148278

149279
if (!response.tool_calls?.length) {
150-
return JSON.stringify({ error: "LLM did not call render_a2ui" });
280+
return JSON.stringify({
281+
error: "LLM did not call _design_a2ui_surface",
282+
});
151283
}
152284

153-
const args = (response.tool_calls[0].args ?? {}) as Record<string, unknown>;
285+
const args = (response.tool_calls[0].args ?? {}) as Record<
286+
string,
287+
unknown
288+
>;
154289
const surfaceId = (args.surfaceId as string) ?? "dynamic-surface";
155-
const catalogId = (args.catalogId as string) ?? CUSTOM_CATALOG_ID;
156-
const components = (args.components as unknown[]) ?? [];
290+
// Force the canonical catalog ID — the secondary LLM has been observed
291+
// hallucinating IDs from sibling demos when context is sparse.
292+
const catalogId = CUSTOM_CATALOG_ID;
293+
const components = sanitizeA2uiComponents(
294+
(args.components as unknown[]) ?? [],
295+
);
157296
const data = (args.data as Record<string, unknown>) ?? {};
158297

298+
if (!hasRootComponent(components)) {
299+
return JSON.stringify({
300+
error:
301+
"LLM produced no valid root component for the A2UI surface.",
302+
});
303+
}
304+
159305
const ops: unknown[] = [
160306
createSurfaceOp(surfaceId, catalogId),
161307
updateComponentsOp(surfaceId, components),
@@ -169,14 +315,21 @@ const generateA2ui = tool(
169315
{
170316
name: "generate_a2ui",
171317
description:
172-
"Generate dynamic A2UI components based on the conversation. A secondary LLM designs the UI schema and data. The result is returned as an a2ui_operations container for the A2UI middleware to detect and forward to the frontend renderer.",
318+
"Generate dynamic A2UI components based on the conversation. " +
319+
"A secondary LLM designs the UI schema and data. The result is " +
320+
"returned as an a2ui_operations container for the A2UI middleware " +
321+
"to detect and forward to the frontend renderer.",
173322
schema: z.object({}),
174323
},
175324
);
176325

177-
const tools = [generateA2ui];
326+
const tools = [generateA2uiTool];
327+
328+
// Standard ToolNode — invokes tools via the LangChain runtime so that
329+
// `OnToolEnd` events fire and the AG-UI adapter emits `TOOL_CALL_RESULT`.
330+
const _toolNode = new ToolNode(tools);
178331

179-
async function chatNode(state: AgentState, config: RunnableConfig) {
332+
async function chatNode(state: AgentState) {
180333
const model = new ChatOpenAI({ temperature: 0, model: "gpt-4.1" });
181334

182335
const modelWithTools = model.bindTools!([
@@ -186,24 +339,36 @@ async function chatNode(state: AgentState, config: RunnableConfig) {
186339

187340
const systemMessage = new SystemMessage({ content: SYSTEM_PROMPT });
188341

189-
// Forward the full state into the tool execution context via `configurable`
190-
// so `generate_a2ui` can access messages + copilotkit.context.
191-
const augmentedConfig: RunnableConfig = {
192-
...config,
193-
configurable: {
194-
...(config.configurable ?? {}),
195-
state,
196-
},
197-
};
198-
199-
const response = await modelWithTools.invoke(
200-
[systemMessage, ...state.messages],
201-
augmentedConfig,
202-
);
342+
const response = await modelWithTools.invoke([
343+
systemMessage,
344+
...state.messages,
345+
]);
203346

204347
return { messages: response };
205348
}
206349

350+
/**
351+
* State-aware tool node wrapper.
352+
*
353+
* Snapshots the current graph state into the module-level `_currentState`
354+
* variable, then delegates to the real `ToolNode`. This gives
355+
* `generateA2uiTool` access to `state.messages` and
356+
* `state.copilotkit.context` while preserving the standard LangChain tool
357+
* invocation path (OnToolEnd events -> TOOL_CALL_RESULT AG-UI events ->
358+
* A2UI middleware detection).
359+
*/
360+
async function stateAwareToolNode(
361+
state: AgentState,
362+
config: Record<string, unknown>,
363+
) {
364+
_currentState = state;
365+
try {
366+
return await _toolNode.invoke(state, config);
367+
} finally {
368+
_currentState = null;
369+
}
370+
}
371+
207372
function shouldContinue({ messages, copilotkit }: AgentState) {
208373
const lastMessage = messages[messages.length - 1] as AIMessage;
209374

@@ -221,10 +386,16 @@ function shouldContinue({ messages, copilotkit }: AgentState) {
221386

222387
const workflow = new StateGraph(AgentStateAnnotation)
223388
.addNode("chat_node", chatNode)
224-
.addNode("tool_node", new ToolNode(tools))
389+
.addNode(
390+
"tool_node",
391+
stateAwareToolNode as unknown as typeof chatNode,
392+
)
225393
.addEdge(START, "chat_node")
226394
.addEdge("tool_node", "chat_node")
227-
.addConditionalEdges("chat_node", shouldContinue as any);
395+
.addConditionalEdges(
396+
"chat_node",
397+
shouldContinue as unknown as (state: AgentState) => string,
398+
);
228399

229400
const memory = new MemorySaver();
230401

0 commit comments

Comments
 (0)