Skip to content

Commit 5df9fcb

Browse files
authored
feat(examples-chat): replace sub-LLM dispatch with parent-emits-envelopes (#259)
* feat(examples-chat): Pydantic A2UI envelope tool Parent-LLM-bound tool render_a2ui_surface accepts envelopes as typed structured arguments. OpenAI strict-mode bind_tools will validate against the Pydantic schema. Body serializes via model_dump(exclude_none=True) so optional fields don't leak as null into the wire JSON. * feat(examples-chat): Python envelope-args normalizer Parity with libs/chat envelope-normalizer.ts. Accepts envelopes/envelope/ positional/flat shapes the parent LLM may emit under non-strict tool binding. Returns a canonical envelope list or None. * feat(examples-chat): parent LLM emits A2UI envelopes directly Replaces the two-LLM hop (parent → tool body's sub-LLM) with a single parent LLM bound to render_a2ui_surface(envelopes: list[A2uiEnvelope]) under OpenAI strict mode. The A2UI v1 schema prompt is appended to the parent's system prompt with explicit envelope-emission ordering so the surface mounts as soon as surfaceUpdate parses. emit_generated_surface keeps its PR #255 job: read the validated envelope list from ToolMessage, apply the static reorder for defence-in-depth, wrap with A2UI_PREFIX, replace upstream AI message in place (single-bubble invariant). The new envelope-emission flow already streams natively via the parent LLM's tool_call_chunks; PR 3 attaches a callback handler that sidebands the chunks as a2ui-partial custom events for the live UX.
1 parent 903e2db commit 5df9fcb

7 files changed

Lines changed: 246 additions & 34 deletions

File tree

examples/chat/python/src/graph.py

Lines changed: 26 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@
4242
from langchain_core.tools import tool
4343
from langgraph_sdk import get_client
4444

45+
from src.streaming.envelope_tool import render_a2ui_surface
46+
from src.streaming.envelope_normalizer import normalize_envelope_args
47+
from src.schemas.a2ui_v1 import A2UI_V1_SCHEMA_PROMPT
48+
4549

4650
# Module-level singleton client; created lazily on first thread-title write.
4751
_threads_client = None
@@ -156,7 +160,7 @@ async def _maybe_write_thread_title(state: "State", config: RunnableConfig) -> N
156160
"'render Y', 'show a Z', 'create a form for', 'make a card with' — "
157161
"you MUST IMMEDIATELY dispatch the schema-generation tool bound "
158162
"to the conversation. Exactly ONE such tool is bound per request: "
159-
"either `generate_a2ui_schema` or `generate_json_render_spec`. "
163+
"either `render_a2ui_surface` or `generate_json_render_spec`. "
160164
"Do NOT ask clarifying questions about platform, framework, fields, "
161165
"validation, styling, or anything else. Do NOT describe the UI in "
162166
"prose. Do NOT request more details from the user. The tool ITSELF "
@@ -326,23 +330,6 @@ async def research(topic: str, subagent_type: str = "research") -> str:
326330
A2UI_PREFIX = "---a2ui_JSON---"
327331

328332

329-
@tool
330-
async def generate_a2ui_schema(request: str) -> str:
331-
"""Dispatch the A2UI schema sub-agent to render a UI surface in A2UI
332-
v1 wire format. Use this when the user asks for UI/forms/cards and
333-
state.gen_ui_mode is 'a2ui'. Pass the user's request verbatim as the
334-
`request` argument. The sub-agent returns a JSON array of v1
335-
envelopes (surfaceUpdate, optional dataModelUpdate, beginRendering)
336-
that the post-process node wraps for the chat composition."""
337-
from src.schemas.a2ui_v1 import A2UI_V1_SCHEMA_PROMPT
338-
llm = ChatOpenAI(model="gpt-5-mini", temperature=0)
339-
response = await llm.ainvoke([
340-
SystemMessage(content=A2UI_V1_SCHEMA_PROMPT),
341-
HumanMessage(content=request),
342-
])
343-
return _as_text(response.content).strip()
344-
345-
346333
@tool
347334
async def generate_json_render_spec(request: str) -> str:
348335
"""Dispatch the json-render schema sub-agent to render a UI surface
@@ -403,13 +390,26 @@ async def generate(state: State, config: RunnableConfig) -> dict:
403390
# side of the conditional resolves at execution time.
404391
gen_ui_mode = state.get("gen_ui_mode") or "a2ui"
405392
gen_ui_tool = (
406-
generate_a2ui_schema if gen_ui_mode == "a2ui"
393+
render_a2ui_surface if gen_ui_mode == "a2ui"
407394
else generate_json_render_spec
408395
)
409-
llm = ChatOpenAI(**kwargs).bind_tools([
410-
search_documents, request_approval, research, gen_ui_tool,
411-
])
412-
messages = [SystemMessage(content=SYSTEM_PROMPT)] + state["messages"]
396+
# Strict mode is enabled for the envelope-emission tool so OpenAI enforces
397+
# the canonical {envelopes: [...]} argument shape; the JS bridge and Python
398+
# normalizer treat the non-canonical shapes as safety nets.
399+
llm = ChatOpenAI(**kwargs).bind_tools(
400+
[search_documents, request_approval, research, gen_ui_tool],
401+
strict=True if gen_ui_mode == "a2ui" else False,
402+
)
403+
# Append A2UI v1 schema to system prompt when in a2ui mode, so the parent
404+
# LLM knows how to construct the envelopes directly.
405+
system = SYSTEM_PROMPT
406+
if gen_ui_mode == "a2ui":
407+
system = SYSTEM_PROMPT + "\n\n--- A2UI v1 SCHEMA ---\n" + A2UI_V1_SCHEMA_PROMPT + (
408+
"\n\nWhen rendering UI in a2ui mode, emit envelopes in this order: "
409+
"surfaceUpdate FIRST, then beginRendering, then any dataModelUpdate "
410+
"entries. This lets the client mount the surface as early as possible."
411+
)
412+
messages = [SystemMessage(content=system)] + state["messages"]
413413
response = await llm.ainvoke(messages)
414414
return {"messages": [response]}
415415

@@ -437,7 +437,7 @@ def after_tools(state: State) -> Literal["emit_generated_surface", "generate"]:
437437
if isinstance(prior, AIMessage) and prior.tool_calls:
438438
for tc in prior.tool_calls:
439439
if tc.get("id") == m.tool_call_id and tc.get("name") in (
440-
"generate_a2ui_schema", "generate_json_render_spec",
440+
"render_a2ui_surface", "generate_json_render_spec",
441441
):
442442
return "emit_generated_surface"
443443
break
@@ -480,7 +480,7 @@ async def emit_generated_surface(state: State) -> dict:
480480
if not payload:
481481
return {}
482482

483-
if tool_name == "generate_a2ui_schema":
483+
if tool_name == "render_a2ui_surface":
484484
# Sub-LLM returns a JSON array of v1 envelopes. Convert to JSONL
485485
# (one envelope per line) and prepend the classifier sentinel.
486486
try:
@@ -645,7 +645,7 @@ async def attach_citations(state: State) -> dict:
645645
_builder.add_node("generate", generate)
646646
_builder.add_node("tools", ToolNode([
647647
search_documents, request_approval, research,
648-
generate_a2ui_schema, generate_json_render_spec,
648+
render_a2ui_surface, generate_json_render_spec,
649649
]))
650650
_builder.add_node("emit_generated_surface", emit_generated_surface)
651651
_builder.add_node("attach_citations", attach_citations)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# SPDX-License-Identifier: MIT
2+
"""Backend streaming helpers for progressive A2UI envelope emission."""
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# SPDX-License-Identifier: MIT
2+
"""Normalises the four envelope-args shapes the parent LLM may emit into
3+
a canonical envelope list. Parity with libs/chat/src/lib/a2ui/envelope-normalizer.ts.
4+
5+
The spike (examples/chat/python/spike/parent_envelope_quality.py) observed
6+
these shapes across gpt-5-mini and gpt-5; strict-mode tool binding should
7+
eliminate the non-canonical ones in production, but this normalizer is
8+
the safety net.
9+
"""
10+
from __future__ import annotations
11+
12+
from typing import Any
13+
14+
_ENVELOPE_KEYS = ("surfaceUpdate", "beginRendering", "dataModelUpdate", "deleteSurface")
15+
16+
17+
def normalize_envelope_args(args: Any) -> list[dict] | None:
18+
"""Return a canonical envelope list, or None if `args` is unrecognised."""
19+
if not isinstance(args, dict) or not args:
20+
return None
21+
# (a) canonical {envelopes: [...]}
22+
envelopes = args.get("envelopes")
23+
if isinstance(envelopes, list):
24+
return envelopes
25+
# (b) singular {envelope: [...]} typo
26+
envelope = args.get("envelope")
27+
if isinstance(envelope, list):
28+
return envelope
29+
keys = list(args.keys())
30+
# (c) positional keys {"0": env, "1": env, ...}
31+
if keys and all(isinstance(k, str) and k.isdigit() for k in keys):
32+
return [args[k] for k in sorted(keys, key=int)]
33+
# (d) flat single envelope
34+
if any(k in args for k in _ENVELOPE_KEYS):
35+
return [args]
36+
return None
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# SPDX-License-Identifier: MIT
2+
"""Parent-LLM-bound tool that emits A2UI v1 envelopes as structured tool
3+
arguments. Replaces the old two-LLM `generate_a2ui_schema` flow (parent
4+
calls a sub-LLM that produces envelopes); the parent now emits envelopes
5+
directly so the natural token stream IS the surface-rendering stream.
6+
7+
The Pydantic schemas enable OpenAI strict-mode validation when the tool
8+
is bound via `bind_tools([..., render_a2ui_surface], strict=True)`.
9+
"""
10+
from __future__ import annotations
11+
12+
import json
13+
from typing import Optional
14+
15+
from langchain_core.tools import tool
16+
from pydantic import BaseModel, Field
17+
18+
19+
class SurfaceUpdate(BaseModel):
20+
"""Component-tree envelope. Required first envelope per turn."""
21+
surfaceId: str = Field(description="Stable identifier for this surface.")
22+
components: list[dict] = Field(
23+
description="Component tree as a list of {id, type, props} objects."
24+
)
25+
26+
27+
class BeginRendering(BaseModel):
28+
"""Render-start envelope. Required; identifies the root component."""
29+
surfaceId: str
30+
root: str = Field(description="Component id of the surface root.")
31+
styles: Optional[dict] = None
32+
33+
34+
class DataModelUpdate(BaseModel):
35+
"""Initial state envelope. Optional; one per state path the surface binds to."""
36+
surfaceId: str
37+
path: Optional[str] = None
38+
contents: list[dict] = Field(
39+
description="Entries: {key, valueString|valueNumber|valueBoolean|valueMap}."
40+
)
41+
42+
43+
class A2uiEnvelope(BaseModel):
44+
"""Single A2UI v1 envelope. Exactly one of the three discriminators
45+
is set per envelope."""
46+
surfaceUpdate: Optional[SurfaceUpdate] = None
47+
beginRendering: Optional[BeginRendering] = None
48+
dataModelUpdate: Optional[DataModelUpdate] = None
49+
50+
51+
@tool
52+
def render_a2ui_surface(envelopes: list[A2uiEnvelope]) -> str:
53+
"""Render a UI surface using A2UI v1 envelopes. Emit:
54+
- exactly one `surfaceUpdate` (component tree),
55+
- exactly one `beginRendering` (root reference),
56+
- zero or more `dataModelUpdate` entries (initial state).
57+
58+
Envelope order in this call should be: surfaceUpdate, beginRendering,
59+
then any dataModelUpdate entries (so the surface mounts and per-component
60+
placeholders show before initial state arrives).
61+
"""
62+
if not envelopes:
63+
raise ValueError("render_a2ui_surface requires at least one envelope")
64+
return json.dumps([e.model_dump(exclude_none=True) for e in envelopes])
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""Parity tests with libs/chat/src/lib/a2ui/envelope-normalizer.spec.ts."""
2+
import pytest
3+
4+
from src.streaming.envelope_normalizer import normalize_envelope_args
5+
6+
7+
class TestNormalizeEnvelopeArgs:
8+
def test_canonical_envelopes_shape(self):
9+
args = {"envelopes": [{"surfaceUpdate": {"surfaceId": "s", "components": []}}]}
10+
assert normalize_envelope_args(args) == args["envelopes"]
11+
12+
def test_singular_envelope_typo_shape(self):
13+
args = {"envelope": [{"beginRendering": {"surfaceId": "s", "root": "r"}}]}
14+
assert normalize_envelope_args(args) == args["envelope"]
15+
16+
def test_positional_keys_unflattened_in_numeric_order(self):
17+
e1 = {"surfaceUpdate": {"surfaceId": "s", "components": []}}
18+
e2 = {"beginRendering": {"surfaceId": "s", "root": "r"}}
19+
args = {"1": e2, "0": e1}
20+
assert normalize_envelope_args(args) == [e1, e2]
21+
22+
def test_flat_single_envelope_wrapped_in_list(self):
23+
args = {"surfaceUpdate": {"surfaceId": "s", "components": []}}
24+
assert normalize_envelope_args(args) == [args]
25+
26+
def test_empty_object_returns_none(self):
27+
assert normalize_envelope_args({}) is None
28+
29+
def test_non_dict_input_returns_none(self):
30+
assert normalize_envelope_args(None) is None
31+
assert normalize_envelope_args("x") is None
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""Tests for the parent-emits-envelopes tool used by the GenUI flow."""
2+
import json
3+
4+
import pytest
5+
6+
from src.streaming.envelope_tool import (
7+
SurfaceUpdate,
8+
BeginRendering,
9+
DataModelUpdate,
10+
A2uiEnvelope,
11+
render_a2ui_surface,
12+
)
13+
14+
15+
class TestPydanticEnvelopeModels:
16+
def test_surface_update_round_trips(self):
17+
m = SurfaceUpdate(surfaceId="s1", components=[{"id": "c", "type": "text", "props": {}}])
18+
assert m.surfaceId == "s1"
19+
assert m.components == [{"id": "c", "type": "text", "props": {}}]
20+
21+
def test_begin_rendering_required_fields(self):
22+
m = BeginRendering(surfaceId="s1", root="c")
23+
assert m.root == "c"
24+
25+
def test_data_model_update_path_is_optional(self):
26+
m = DataModelUpdate(surfaceId="s1", contents=[{"key": "k", "valueString": "v"}])
27+
assert m.path is None
28+
29+
def test_a2ui_envelope_accepts_surface_update_field(self):
30+
e = A2uiEnvelope(surfaceUpdate={"surfaceId": "s", "components": []})
31+
assert e.surfaceUpdate is not None
32+
assert e.beginRendering is None
33+
assert e.dataModelUpdate is None
34+
35+
36+
class TestRenderA2uiSurfaceTool:
37+
def test_serializes_envelopes_to_json_string(self):
38+
envelopes = [
39+
{"surfaceUpdate": {"surfaceId": "s", "components": [{"id": "c", "type": "text", "props": {}}]}},
40+
{"beginRendering": {"surfaceId": "s", "root": "c"}},
41+
]
42+
result = render_a2ui_surface.invoke({"envelopes": envelopes})
43+
parsed = json.loads(result)
44+
assert isinstance(parsed, list)
45+
assert len(parsed) == 2
46+
assert "surfaceUpdate" in parsed[0]
47+
assert "beginRendering" in parsed[1]
48+
49+
def test_strips_none_fields_via_exclude_none(self):
50+
envelopes = [{"surfaceUpdate": {"surfaceId": "s", "components": []}}]
51+
result = render_a2ui_surface.invoke({"envelopes": envelopes})
52+
parsed = json.loads(result)
53+
# beginRendering / dataModelUpdate are None on this envelope and should be stripped.
54+
assert "beginRendering" not in parsed[0]
55+
assert "dataModelUpdate" not in parsed[0]
56+
57+
def test_raises_on_empty_envelopes_list(self):
58+
with pytest.raises(ValueError):
59+
render_a2ui_surface.invoke({"envelopes": []})

examples/chat/python/tests/test_graph_smoke.py

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,8 @@ def test_state_graph_topology_unchanged_after_research():
9292

9393
@pytest.mark.smoke
9494
def test_genui_tools_exist():
95-
from src.graph import generate_a2ui_schema, generate_json_render_spec
96-
assert generate_a2ui_schema.name == "generate_a2ui_schema"
95+
from src.graph import render_a2ui_surface, generate_json_render_spec
96+
assert render_a2ui_surface.name == "render_a2ui_surface"
9797
assert generate_json_render_spec.name == "generate_json_render_spec"
9898

9999

@@ -174,19 +174,19 @@ def test_replaces_tool_call_ai_in_place_same_id(self):
174174
tool_call_ai = AIMessage(
175175
id="ai-1",
176176
content=[
177-
{"type": "function_call", "name": "generate_a2ui_schema",
177+
{"type": "function_call", "name": "render_a2ui_surface",
178178
"arguments": '{"request":"r"}'}
179179
],
180180
tool_calls=[{
181181
"id": "call_1",
182-
"name": "generate_a2ui_schema",
182+
"name": "render_a2ui_surface",
183183
"args": {"request": "r"},
184184
"type": "tool_call",
185185
}],
186186
)
187187
tool_msg = ToolMessage(
188188
tool_call_id="call_1",
189-
name="generate_a2ui_schema",
189+
name="render_a2ui_surface",
190190
content='[{"surfaceUpdate":{"surfaceId":"s1","components":[]}},'
191191
'{"beginRendering":{"surfaceId":"s1","root":""}}]',
192192
)
@@ -207,7 +207,7 @@ def test_replaces_tool_call_ai_in_place_same_id(self):
207207
# Content carries the wrapped surface payload.
208208
assert "---a2ui_JSON---" in replacement_ai.content
209209
# tool_calls is preserved so detection (frontend isGenuiTurn) still fires.
210-
assert any(tc.get("name") == "generate_a2ui_schema" for tc in replacement_ai.tool_calls)
210+
assert any(tc.get("name") == "render_a2ui_surface" for tc in replacement_ai.tool_calls)
211211

212212
def test_beginRendering_envelope_ordering(self):
213213
"""emit reorders the wrapped envelopes so beginRendering lands
@@ -219,14 +219,14 @@ def test_beginRendering_envelope_ordering(self):
219219
content=[],
220220
tool_calls=[{
221221
"id": "call_2",
222-
"name": "generate_a2ui_schema",
222+
"name": "render_a2ui_surface",
223223
"args": {"request": "r"},
224224
"type": "tool_call",
225225
}],
226226
)
227227
tool_msg = ToolMessage(
228228
tool_call_id="call_2",
229-
name="generate_a2ui_schema",
229+
name="render_a2ui_surface",
230230
content='['
231231
'{"surfaceUpdate":{"surfaceId":"s","components":[]}},'
232232
'{"dataModelUpdate":{"surfaceId":"s","contents":[]}},'
@@ -252,3 +252,23 @@ def test_beginRendering_envelope_ordering(self):
252252
# The remaining dataModelUpdate envelopes follow.
253253
assert "dataModelUpdate" in parsed[2]
254254
assert "dataModelUpdate" in parsed[3]
255+
256+
257+
class TestParentEmitsEnvelopes:
258+
def test_render_a2ui_surface_is_bound_for_a2ui_mode(self):
259+
"""Sanity: the parent LLM's generate node binds render_a2ui_surface
260+
when gen_ui_mode='a2ui'. We import the graph module and check the
261+
tools registered on ToolNode."""
262+
from src.graph import _builder
263+
264+
tool_node = _builder.nodes["tools"].runnable
265+
# ToolNode keeps a `.tools_by_name` dict
266+
tool_names = list(tool_node.tools_by_name.keys())
267+
assert "render_a2ui_surface" in tool_names
268+
269+
def test_generate_a2ui_schema_tool_is_removed(self):
270+
"""The old sub-LLM-dispatching tool must be removed from the graph."""
271+
from src.graph import _builder
272+
tool_node = _builder.nodes["tools"].runnable
273+
tool_names = list(tool_node.tools_by_name.keys())
274+
assert "generate_a2ui_schema" not in tool_names

0 commit comments

Comments
 (0)