55`examples/integrations/langgraph-python/agent/src/a2ui_dynamic_schema.py`):
66
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) with the
9- registered client catalog injected as `copilotkit.context`.
8+ invokes a secondary LLM bound to `_design_a2ui_surface` (tool_choice forced)
9+ with the registered client catalog injected as `copilotkit.context`. The
10+ internal tool is intentionally NOT named `render_a2ui` to avoid the A2UI
11+ middleware's default tool-call intercept (`a2uiToolNames`).
1012- The tool result returns an `a2ui_operations` container which the A2UI
1113 middleware detects in the tool-call result and forwards to the frontend
1214 renderer.
3739CUSTOM_CATALOG_ID = "declarative-gen-ui-catalog"
3840
3941
42+ # Internal tool bound only to the secondary LLM inside `generate_a2ui` for
43+ # structured output. Intentionally NOT named `render_a2ui` because the A2UI
44+ # middleware default-intercepts tool calls by that name from the run's event
45+ # stream and synthesises ACTIVITY_SNAPSHOT events from the LLM's RAW streaming
46+ # args (catalogId + components, before our Python code can validate). That
47+ # bypass is what surfaced the "Cannot create component root without a type"
48+ # infinite-loop on the deployed declarative-gen-ui demo. Renaming sidesteps
49+ # the middleware's intercept list (`a2uiToolNames`).
4050@lc_tool
41- def render_a2ui (
51+ def _design_a2ui_surface (
4252 surfaceId : str ,
4353 catalogId : str ,
4454 components : list [dict ],
4555 data : dict | None = None ,
4656) -> str :
47- """Render a dynamic A2UI v0.9 surface.
57+ """Design a dynamic A2UI v0.9 surface.
4858
4959 Args:
5060 surfaceId: Unique surface identifier.
@@ -53,7 +63,26 @@ def render_a2ui(
5363 component must have id "root".
5464 data: Optional initial data model for the surface.
5565 """
56- return "rendered"
66+ return "designed"
67+
68+
69+ _GENERATE_A2UI_PROMPT_HEADER = f"""\
70+ You are designing a dynamic A2UI v0.9 surface. Call the `_design_a2ui_surface`
71+ tool with a flat component array.
72+
73+ Hard requirements (failing any of these breaks the renderer — be strict):
74+ - `catalogId` MUST be exactly: "{ CUSTOM_CATALOG_ID } "
75+ - `surfaceId` is a short kebab-case identifier (e.g. "kpi-dashboard").
76+ - `components` is a FLAT array. Every entry MUST include both an `id` (unique
77+ string) AND a `component` (string — the catalog component name). The root
78+ entry MUST have `id: "root"` AND a valid `component` field — never emit
79+ a root entry without a component type.
80+ - Container components (Row, Column, Card) reference children by id via their
81+ `children` (array of strings) or `child` (single string) prop. Do NOT inline
82+ children objects. Define each child as its own entry in the flat array and
83+ reference its id.
84+ - Use only catalog component names listed in the schema below.
85+ """
5786
5887
5988@tool ()
@@ -69,34 +98,55 @@ def generate_a2ui(runtime: ToolRuntime[Any]) -> str:
6998 # Pull the A2UI component schema + usage guidelines from the runtime's
7099 # `copilotkit.context` (the runtime injects them automatically when the
71100 # frontend registers a catalog via `<CopilotKit a2ui={{ catalog }}>`).
101+ # We prepend an explicit instruction header because the runtime context
102+ # alone leaves room for the LLM to hallucinate catalog IDs or emit a root
103+ # component without a `component` field — both surface as "Cannot create
104+ # component root without a type" infinite-loops in the renderer.
72105 context_entries = runtime .state .get ("copilotkit" , {}).get ("context" , [])
73106 context_text = "\n \n " .join (
74107 entry .get ("value" , "" )
75108 for entry in context_entries
76109 if isinstance (entry , dict ) and entry .get ("value" )
77110 )
78111
112+ prompt = f"{ _GENERATE_A2UI_PROMPT_HEADER } \n \n { context_text } " .strip ()
113+
79114 model = ChatOpenAI (model = "gpt-4.1" )
80115 model_with_tool = model .bind_tools (
81- [render_a2ui ],
82- tool_choice = "render_a2ui " ,
116+ [_design_a2ui_surface ],
117+ tool_choice = "_design_a2ui_surface " ,
83118 )
84119
85120 response = model_with_tool .invoke (
86- [SystemMessage (content = context_text ), * messages ],
121+ [SystemMessage (content = prompt ), * messages ],
87122 )
88123
89124 if not response .tool_calls :
90- return json .dumps ({"error" : "LLM did not call render_a2ui " })
125+ return json .dumps ({"error" : "LLM did not call _design_a2ui_surface " })
91126
92127 tool_call = response .tool_calls [0 ]
93128 args = tool_call ["args" ]
94129
95130 surface_id = args .get ("surfaceId" , "dynamic-surface" )
96- catalog_id = args .get ("catalogId" , CUSTOM_CATALOG_ID )
131+ # Force the canonical catalog ID — the secondary LLM has been observed
132+ # hallucinating IDs from sibling demos when context is sparse.
133+ catalog_id = CUSTOM_CATALOG_ID
97134 components = args .get ("components" , [])
98135 data = args .get ("data" , {})
99136
137+ # Defensive: every component must have an `id` AND a `component` field, or
138+ # the frontend renderer throws "Cannot create component <id> without a
139+ # type" and never recovers. Drop malformed entries so a valid surface
140+ # still renders.
141+ components = [
142+ c for c in components
143+ if isinstance (c , dict ) and c .get ("id" ) and c .get ("component" )
144+ ]
145+ if not any (c .get ("id" ) == "root" for c in components ):
146+ return json .dumps ({
147+ "error" : "LLM produced no valid root component for the A2UI surface."
148+ })
149+
100150 ops = [
101151 a2ui .create_surface (surface_id , catalog_id = catalog_id ),
102152 a2ui .update_components (surface_id , components ),
0 commit comments