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
1734import { z } from "zod" ;
18- import { RunnableConfig } from "@langchain/core/runnables" ;
1935import { tool } from "@langchain/core/tools" ;
36+ import {
37+ AIMessage ,
38+ SystemMessage ,
39+ } from "@langchain/core/messages" ;
2040import { ToolNode } from "@langchain/langgraph/prebuilt" ;
21- import { AIMessage , SystemMessage } from "@langchain/core/messages" ;
2241import {
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+
55115const 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+
207372function shouldContinue ( { messages, copilotkit } : AgentState ) {
208373 const lastMessage = messages [ messages . length - 1 ] as AIMessage ;
209374
@@ -221,10 +386,16 @@ function shouldContinue({ messages, copilotkit }: AgentState) {
221386
222387const 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
229400const memory = new MemorySaver ( ) ;
230401
0 commit comments