From 38edc899d23c5f0b1916caab917c7c739988c53f Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 16 May 2026 14:01:29 -0700 Subject: [PATCH 01/12] =?UTF-8?q?docs:=20spec=20PR=204=20=E2=80=94=20c-a2u?= =?UTF-8?q?i=20LLM-driven=20aviation=20booking=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final PR of the c-* aviation rollout. Replaces the hardcoded contact form with an LLM-authored booking form that emits valid A2UI JSONL via .with_structured_output() + Pydantic schemas. Post-submit emits a SECOND LLM-authored surface listing matching flights from find_routes(). Retry policy: 2 retries on validation failure, then hardcoded sentinel fallback. Co-Authored-By: Claude Opus 4.7 --- ...26-05-16-c-a2ui-aviation-booking-design.md | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-16-c-a2ui-aviation-booking-design.md diff --git a/docs/superpowers/specs/2026-05-16-c-a2ui-aviation-booking-design.md b/docs/superpowers/specs/2026-05-16-c-a2ui-aviation-booking-design.md new file mode 100644 index 000000000..ddfe6b5b4 --- /dev/null +++ b/docs/superpowers/specs/2026-05-16-c-a2ui-aviation-booking-design.md @@ -0,0 +1,200 @@ +# c-a2ui LLM-Driven Aviation Booking Form — Design + +**Date:** 2026-05-16 +**Status:** Spec — pending implementation plan +**Series:** PR 4 of 4 in the c-* aviation theme rollout + +## Goal + +Replace the hardcoded contact-form JSONL in `cockpit/langgraph/streaming/python/src/a2ui_graph.py` with an **LLM-authored** aviation flight-booking form, plus an LLM-authored follow-up "flight results" surface after submit. Constrain the LLM's JSON output to valid A2UI shapes via `.with_structured_output()` + Pydantic schemas, with retry on validation failure. + +The existing code's docstring says *"The graph does NOT use an LLM for UI generation — A2UI JSONL requires exact format adherence that LLMs cannot reliably provide."* This PR proves the opposite is achievable when the LLM emits **schema-constrained structured output** rather than raw JSON strings. + +Out of scope: +- Multi-leg / round-trip itineraries +- Real payment surface +- "Select flight" booking action — the button emits an acknowledgement only + +## Decisions + +| # | Decision | Choice | +|---|---|---| +| 1 | LLM role | LLM emits the A2UI components directly via `.with_structured_output(BookingFormSpec)` / `.with_structured_output(FlightResultsSpec)`. Code wraps the validated output in the three deterministic envelopes (`createSurface`, `updateDataModel`, `updateComponents`). | +| 2 | Post-submit behavior | LLM emits a SECOND A2UI surface — a flight-results list — after calling `find_routes()` for the submitted origin/destination. Two LLM-authored surfaces per session. | +| 3 | Retry policy | On `ValidationError` / `OutputParserException`, retry up to 2× with the failure message re-injected into the prompt. After 3 total attempts, fall back to a hardcoded sentinel form. | +| 4 | LLM choice | `gpt-5` with `reasoning_effort="low"`. Matches PR #372's pattern of using gpt-5 for tool-discipline; "low" gives slightly more headroom than "minimal" for schema compliance. | + +## Architecture + +3-node graph (replaces today's single `create_form` node): + +``` +START → route ─→ build_form (first turn → emit booking-form A2UI envelopes) + └─────→ search_flights (form submit → call find_routes + emit results envelopes) + → END +``` + +`route(state)` inspects the last message: +- If `content` parses as `{"type": "a2ui_event", "context": {"formId": "booking"}}` and includes form data → goto `search_flights` +- Otherwise → goto `build_form` + +`build_form(state)`: +1. LLM call: `_llm.with_structured_output(BookingFormSpec).ainvoke(messages)` with system prompt listing airports/fare-classes/required components + a few-shot example envelope +2. Retry up to 2× on validation failure (max 3 attempts total) +3. Wrap result in 3 envelopes: `createSurface(surface_id, catalogId='basic', sendDataModel=True)`, `updateDataModel(value=data_model)`, `updateComponents(components=components)` +4. Concatenate as JSONL, prepend `A2UI_PREFIX`, emit as `AIMessage(content=jsonl)` + +`search_flights(state)`: +1. Parse form-submit payload → `(origin, dest, date, passengers, fare_class)` +2. Call `find_routes(origin, dest)` from `src.aviation_tools` (already exists from PR 1) +3. LLM call: `_llm.with_structured_output(FlightResultsSpec).ainvoke(messages + flight context)` +4. Wrap + emit same way as `build_form` + +## Pydantic schemas + +```python +from typing import Any, Literal +from pydantic import BaseModel, Field + +class A2uiComponent(BaseModel): + """Single component in an A2UI updateComponents envelope. + + Literal[...] on `component` is the gate that keeps the LLM from + inventing component types not in the catalog. + """ + id: str + component: Literal[ + "Column", "Row", "Card", "TextField", "ChoicePicker", + "NumberField", "DatePicker", "CheckBox", "Button", "Divider", + ] + label: str | None = None + title: str | None = None + placeholder: str | None = None + options: list[str] | None = None + value: dict[str, Any] | None = None # {"path": "/origin"} + selected: dict[str, Any] | None = None + children: list[str] | None = None + checks: list[dict[str, Any]] | None = None + action: dict[str, Any] | None = None + +class BookingFormSpec(BaseModel): + surface_id: str = Field(description="Surface id, use 'booking'") + data_model: dict[str, Any] = Field(description="Form prefills, e.g. {origin, dest, date, passengers, fare_class}") + components: list[A2uiComponent] + +class FlightResultsSpec(BaseModel): + surface_id: str = Field(description="Surface id, use 'results'") + data_model: dict[str, Any] + components: list[A2uiComponent] +``` + +## Aviation form contents + +LLM is prompted to include these components: + +| Field | Component | Notes | +|---|---|---| +| Origin | `ChoicePicker` | options = 10 IATA codes: LAX, JFK, SFO, ORD, BOS, ATL, DFW, SEA, MIA, DEN. `selected: {path:"/origin"}` | +| Destination | `ChoicePicker` | same options. `selected: {path:"/dest"}` | +| Departure date | `TextField` | `value: {path:"/date"}`, placeholder "YYYY-MM-DD". Validation check: format YYYY-MM-DD. | +| Passengers | `NumberField` | `value: {path:"/passengers"}`, default 1, min 1, max 9 | +| Fare class | `ChoicePicker` | options = `["Economy", "Premium", "Business", "First"]`. `selected: {path:"/fare_class"}` | +| Submit | `Button` | gated `checks`: origin/dest both set AND different AND date present. `action: {event: {name:"bookingSubmit", context:{formId:"booking"}}}` | + +Default `data_model`: `{"origin": "", "dest": "", "date": "", "passengers": 1, "fare_class": "Economy"}`. + +## Flight-results surface contents + +For each flight returned by `find_routes(origin, dest)`: +- `Card` titled `" flight "` +- Sub-text: `" ( min)"`, aircraft, gate +- "Select" `Button` with `action: {event: {name:"flightSelect", context:{flightId:""}}}` + +If `find_routes()` returns an empty list, emit a `Card` titled `"No flights found"` with a "Modify search" `Button` that re-emits the booking-form surface (text-only acknowledgement is fine for v1). + +## Retry implementation + +```python +async def emit_with_retry(llm_fn, max_attempts=3): + last_err = None + messages = base_messages + for attempt in range(max_attempts): + try: + return await llm_fn(messages) + except (ValidationError, OutputParserException) as err: + last_err = err + messages = base_messages + [ + AIMessage(content=f"Previous attempt failed validation: {err}. Try again, strictly matching the schema."), + ] + raise RuntimeError(f"LLM failed structured output after {max_attempts} attempts: {last_err}") +``` + +## LLM prompts + +**`build_form` system prompt:** +``` +You are an aviation booking-form designer. Emit an A2UI booking form using the structured output schema. Required components: origin picker, destination picker, departure date, passenger count, fare class, submit button. + +Airports (use these IATA codes as ChoicePicker options for both origin and destination): +LAX, JFK, SFO, ORD, BOS, ATL, DFW, SEA, MIA, DEN + +Fare classes (ChoicePicker options): Economy, Premium, Business, First + +Default data_model: {origin:"", dest:"", date:"", passengers:1, fare_class:"Economy"} + +Submit button must: +- Be gated by checks that require origin and dest set, origin != dest, and date present +- Emit action {event: {name:"bookingSubmit", context:{formId:"booking"}}} when clicked + +Wrap fields in a single Card with title "Book a flight" inside a Column root. +``` + +**`search_flights` system prompt:** +``` +You just received a booking submission. Form data: {origin, dest, date, passengers, fare_class}. The find_routes tool returned the following flights: . + +Emit an A2UI results surface using FlightResultsSpec. For each flight, create a Card titled with airline + flight number, listing depart/arrive times, duration, aircraft, gate. Each Card has a "Select" Button whose action emits {event: {name:"flightSelect", context:{flightId:}}}. + +If the flights list is empty, emit a single Card "No flights found" with a "Modify search" Button. +``` + +## Standalone copy + +This PR also updates the per-capability standalone at `cockpit/chat/a2ui/python/` if it exists. Will inspect during implementation and mirror the changes (analytics inlined, separate copy of the schemas) — same pattern as PR 3's standalone treatment. + +## Files modified + +| File | Change | +|---|---| +| `cockpit/langgraph/streaming/python/src/a2ui_graph.py` | Full rewrite — 3 nodes, LLM with structured output, Pydantic schemas, retry helper, find_routes integration | +| `cockpit/chat/a2ui/python/src/...` | Mirror if a standalone exists | + +No frontend changes — chat-lib's A2UI primitives already render any valid envelope. + +## Testing + +**Programmatic:** +- Smoke `build_form` produces a JSONL string with 3 envelope keys (`createSurface`, `updateDataModel`, `updateComponents`) and at least 6 components (origin, dest, date, passengers, fare_class, submit) +- Smoke `search_flights` with a submit payload for LAX→JFK produces a JSONL string referencing at least one Card with a flight number from `aviation_data.FLIGHTS` +- Retry path: monkeypatch the LLM to return invalid JSON on first call → verify retry kicks in and second call succeeds + +**Chrome MCP (gating):** +- `OPENAI_API_KEY` from repo root `.env` +- "I want to fly somewhere" → booking form renders with all 6 fields, airport pickers, fare-class picker, disabled Submit +- Pick LAX origin, JFK dest, date 2026-06-15, 2 passengers, Business → Submit becomes enabled, click it → flight results surface appears with 1+ Cards listing matching flights from `aviation_data` +- Validate no console errors related to A2UI parsing + +## Risks and mitigations + +- **`.with_structured_output()` on gpt-5 may not be fully wired in langchain-openai 1.1.12.** Mitigation: if structured-output mode errors at compile time, fall back to `model_kwargs={"response_format": {"type": "json_schema", "json_schema": {…}}}` (raw Responses-API call). Detect at module load, log a warning, continue. +- **LLM invents component types outside the catalog.** Caught by `Literal[...]` on `A2uiComponent.component`; Pydantic validation fails; retry kicks in. +- **Two surfaces in sequence (`createSurface "booking"` then `createSurface "results"`) — does the chat-lib handle the surface swap?** Will verify in chrome MCP. If not, the second `createSurface` may need `replace: true` (will check A2UI v0.9 envelope spec) or the LLM emits an `updateComponents` re-write to the same surface. +- **LLM latency.** Two structured-output calls per session (one per surface). gpt-5 with reasoning_effort=low is ~2-4s per call. Acceptable for a demo; surfaces stream as soon as emitted. +- **Fallback form on 3-retry exhaustion.** Hardcoded sentinel keeps the demo from crashing; logs a warning so we notice. + +## Out-of-scope follow-ups (track but defer) + +- Multi-leg / round-trip booking +- Real "Select flight" action (currently emits an event only) +- A2UI surface state persistence across thread restart +- Surface replace vs. append semantics — confirm during chrome smoke and document the chat-lib behavior From 6d4630e4a447fef832b8e0550aae5844a5ca7587 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 16 May 2026 14:12:27 -0700 Subject: [PATCH 02/12] =?UTF-8?q?docs:=20plan=20PR=204=20=E2=80=94=20c-a2u?= =?UTF-8?q?i=20LLM-driven=20aviation=20booking=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 11 tasks: Pydantic schemas + envelope helper, LLM with structured output + retry, build_form / search_flights / route nodes, 3-node graph compile, real-LLM smoke, standalone mirror with inlined flight fixtures, build verification, REQUIRED iterative chrome MCP smoke, PR open. Co-Authored-By: Claude Opus 4.7 --- .../2026-05-16-c-a2ui-aviation-booking.md | 887 ++++++++++++++++++ 1 file changed, 887 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-16-c-a2ui-aviation-booking.md diff --git a/docs/superpowers/plans/2026-05-16-c-a2ui-aviation-booking.md b/docs/superpowers/plans/2026-05-16-c-a2ui-aviation-booking.md new file mode 100644 index 000000000..a79569b4f --- /dev/null +++ b/docs/superpowers/plans/2026-05-16-c-a2ui-aviation-booking.md @@ -0,0 +1,887 @@ +# c-a2ui LLM-Driven Aviation Booking Form — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the hardcoded contact-form JSONL in `a2ui_graph.py` with an LLM-authored aviation booking form (and post-submit LLM-authored flight-results surface), both constrained via `.with_structured_output()` + Pydantic schemas with retry. + +**Architecture:** 3-node graph `route → {build_form | search_flights} → END`. Pydantic `A2uiComponent` + `BookingFormSpec` / `FlightResultsSpec` constrain LLM output; code wraps validated output in deterministic `createSurface` / `updateDataModel` / `updateComponents` envelopes. `gpt-5` planner from PR #372 reused. On submit, `find_routes()` (from PR #347) feeds the flight-results LLM call. + +**Tech Stack:** Python 3.12, LangGraph, langchain-openai (`gpt-5` w/ `reasoning_effort="low"`), Pydantic v2, uv. No frontend changes — chat-lib's A2UI primitives already render any valid envelope. + +--- + +## File structure + +**Umbrella backend** (`cockpit/langgraph/streaming/python/`): +- `src/a2ui_graph.py` — REWRITE. 3 nodes + Pydantic schemas + retry helper + envelope wrappers. Single-file containment because the whole module is < 250 LOC. + +**Standalone backend** (`cockpit/chat/a2ui/python/`): +- `src/graph.py` — REWRITE. Mirror of umbrella `a2ui_graph.py` (currently a byte-for-byte copy per `diff`). + +No frontend changes. No `langgraph.json` changes. No standalone `aviation_data.py` needed — the standalone backend's `aviation_tools.py` doesn't exist; this PR inlines the only piece needed (`find_routes` over an inlined `FLIGHTS` list) into the standalone module to keep it self-contained. + +--- + +## Task 1: Pydantic schemas + envelope helpers (umbrella) + +**Files:** +- Modify: `cockpit/langgraph/streaming/python/src/a2ui_graph.py` (full rewrite — but build it in stages; this task lays the schema + envelope helper foundation) + +- [ ] **Step 1: Replace the file with schemas + envelope helpers only** + +Replace `cockpit/langgraph/streaming/python/src/a2ui_graph.py` with: + +```python +""" +A2UI Aviation Booking Form Graph + +LLM-authored A2UI surfaces: +- build_form: emits a flight-booking form via gpt-5 with structured output +- search_flights: post-submit, calls find_routes() and emits a results surface + +Both surfaces are constrained by Pydantic schemas (A2uiComponent + +BookingFormSpec / FlightResultsSpec) and validated; on ValidationError, +the LLM is re-prompted with the error up to 2 retries. After 3 total +attempts, a hardcoded sentinel form is emitted so the demo doesn't 500. + +This replaces the prior hardcoded contact-form implementation. The +prior file's claim that "LLMs cannot reliably emit A2UI JSONL" is +disproven by schema-constrained structured output — the LLM authors +the components list, code wraps it in the deterministic envelope keys. +""" + +import json +import logging +from typing import Any, Literal + +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage +from langchain_openai import ChatOpenAI +from langgraph.graph import StateGraph, MessagesState, END +from langgraph.types import Command +from pydantic import BaseModel, Field, ValidationError + +from src.aviation_tools import find_routes # noqa: E402 + +_logger = logging.getLogger(__name__) + +A2UI_PREFIX = "---a2ui_JSON---" + +# 10 IATA airports from aviation_data.py +AIRPORT_CODES = ["LAX", "JFK", "SFO", "ORD", "BOS", "ATL", "DFW", "SEA", "MIA", "DEN"] +FARE_CLASSES = ["Economy", "Premium", "Business", "First"] + + +# ── Pydantic schemas ──────────────────────────────────────────────────────── + +class A2uiComponent(BaseModel): + """Single A2UI updateComponents entry. + + Literal[...] on `component` is the gate that keeps the LLM from + inventing component types not in the catalog. Pydantic raises + ValidationError if it does, triggering retry. + """ + id: str + component: Literal[ + "Column", "Row", "Card", "TextField", "ChoicePicker", + "NumberField", "DatePicker", "CheckBox", "Button", "Divider", + ] + label: str | None = None + title: str | None = None + placeholder: str | None = None + options: list[str] | None = None + value: dict[str, Any] | None = None + selected: dict[str, Any] | None = None + checked: dict[str, Any] | None = None + children: list[str] | None = None + checks: list[dict[str, Any]] | None = None + action: dict[str, Any] | None = None + + +class _SurfaceSpec(BaseModel): + """Common shape — both booking and results surfaces produce the same + triple (surface_id, data_model, components).""" + surface_id: str = Field(description="Surface id. Use 'booking' for the form, 'results' for flights.") + data_model: dict[str, Any] = Field(description="Initial form/state values, e.g. prefills.") + components: list[A2uiComponent] + + +class BookingFormSpec(_SurfaceSpec): + pass + + +class FlightResultsSpec(_SurfaceSpec): + pass + + +# ── Envelope wrapping ─────────────────────────────────────────────────────── + +def _wrap_envelopes(spec: _SurfaceSpec) -> str: + """Wrap a validated SurfaceSpec into A2UI JSONL with the three required envelopes.""" + lines = [ + json.dumps({"createSurface": { + "surfaceId": spec.surface_id, + "catalogId": "basic", + "sendDataModel": True, + }}), + json.dumps({"updateDataModel": { + "surfaceId": spec.surface_id, + "value": spec.data_model, + }}), + json.dumps({"updateComponents": { + "surfaceId": spec.surface_id, + "components": [c.model_dump(exclude_none=True) for c in spec.components], + }}), + ] + return A2UI_PREFIX + "\n" + "\n".join(lines) + "\n" +``` + +- [ ] **Step 2: Verify module imports cleanly** + +Run: `cd cockpit/langgraph/streaming/python && uv run python -c "from src.a2ui_graph import BookingFormSpec, FlightResultsSpec, _wrap_envelopes, A2UI_PREFIX, AIRPORT_CODES; print(len(AIRPORT_CODES))"` +Expected output: `10` + +- [ ] **Step 3: Commit** + +```bash +git add cockpit/langgraph/streaming/python/src/a2ui_graph.py +git commit -m "feat(c-a2ui): pydantic schemas + envelope wrapper (foundation)" +``` + +--- + +## Task 2: LLM + retry helper + +**Files:** +- Modify: `cockpit/langgraph/streaming/python/src/a2ui_graph.py` (append below the envelope helper from Task 1) + +- [ ] **Step 1: Append LLM setup + retry helper** + +Append to `cockpit/langgraph/streaming/python/src/a2ui_graph.py`: + +```python + + +# ── LLM + retry ───────────────────────────────────────────────────────────── + +# gpt-5 with low reasoning effort: PR #372 established gpt-5 follows +# directive precisely; "low" gives slightly more headroom than "minimal" +# for schema compliance. +_llm = ChatOpenAI( + model="gpt-5", + streaming=True, + reasoning_effort="low", +) + + +async def _emit_with_retry( + spec_cls: type[_SurfaceSpec], + base_messages: list[Any], + max_attempts: int = 3, +) -> _SurfaceSpec: + """Call the LLM with structured output, retrying on validation failure. + + Each retry re-injects the error message so the model has a chance + to correct its output. After max_attempts, raises RuntimeError. + """ + llm = _llm.with_structured_output(spec_cls) + messages = list(base_messages) + last_err: Exception | None = None + for attempt in range(max_attempts): + try: + return await llm.ainvoke(messages) + except ValidationError as err: + last_err = err + _logger.warning( + "A2UI structured-output validation failed (attempt %d/%d): %s", + attempt + 1, max_attempts, err, + ) + messages = list(base_messages) + [ + AIMessage(content=( + f"Previous attempt failed schema validation: {err}. " + "Try again, strictly matching the schema. " + "Do not invent component types outside the Literal list." + )), + ] + raise RuntimeError( + f"LLM failed structured output after {max_attempts} attempts: {last_err}" + ) +``` + +- [ ] **Step 2: Verify module still imports** + +Run: `cd cockpit/langgraph/streaming/python && uv run python -c "from src.a2ui_graph import _llm, _emit_with_retry; print('ok')"` +Expected output: `ok` + +- [ ] **Step 3: Commit** + +```bash +git add cockpit/langgraph/streaming/python/src/a2ui_graph.py +git commit -m "feat(c-a2ui): gpt-5 structured-output LLM + retry helper" +``` + +--- + +## Task 3: build_form node + +**Files:** +- Modify: `cockpit/langgraph/streaming/python/src/a2ui_graph.py` (append below retry helper) + +- [ ] **Step 1: Append build_form node + sentinel fallback** + +Append to `cockpit/langgraph/streaming/python/src/a2ui_graph.py`: + +```python + + +# ── build_form node ───────────────────────────────────────────────────────── + +_BUILD_FORM_SYSTEM = f"""You are an aviation booking-form designer. Emit an A2UI booking form using the structured output schema. + +Required components (in this order, inside a single Card titled "Book a flight" inside a Column root): +1. Origin airport ChoicePicker — options = {AIRPORT_CODES}, selected = {{"path": "/origin"}} +2. Destination airport ChoicePicker — same options, selected = {{"path": "/dest"}} +3. Departure date TextField — value = {{"path": "/date"}}, placeholder "YYYY-MM-DD" +4. Passengers NumberField — value = {{"path": "/passengers"}} +5. Fare class ChoicePicker — options = {FARE_CLASSES}, selected = {{"path": "/fare_class"}} +6. Submit Button — label "Search flights", action = {{"event": {{"name": "bookingSubmit", "context": {{"formId": "booking"}}}}}} + +Submit button MUST be gated by `checks` that require: +- /origin set (call: "required") +- /dest set (call: "required") +- /date set (call: "required") + +Default data_model: {{"origin": "", "dest": "", "date": "", "passengers": 1, "fare_class": "Economy"}} +surface_id MUST be "booking". + +Use unique `id` values for every component (e.g. "root", "card", "origin_field", etc.).""" + + +_SENTINEL_BOOKING_FORM = BookingFormSpec( + surface_id="booking", + data_model={"origin": "", "dest": "", "date": "", "passengers": 1, "fare_class": "Economy"}, + components=[ + A2uiComponent(id="root", component="Column", children=["card"]), + A2uiComponent(id="card", component="Card", title="Book a flight (fallback)", + children=["origin", "dest", "date", "passengers", "fare", "submit"]), + A2uiComponent(id="origin", component="ChoicePicker", label="Origin", + options=AIRPORT_CODES, selected={"path": "/origin"}), + A2uiComponent(id="dest", component="ChoicePicker", label="Destination", + options=AIRPORT_CODES, selected={"path": "/dest"}), + A2uiComponent(id="date", component="TextField", label="Date", + value={"path": "/date"}, placeholder="YYYY-MM-DD"), + A2uiComponent(id="passengers", component="NumberField", label="Passengers", + value={"path": "/passengers"}), + A2uiComponent(id="fare", component="ChoicePicker", label="Fare class", + options=FARE_CLASSES, selected={"path": "/fare_class"}), + A2uiComponent(id="submit", component="Button", label="Search flights", + action={"event": {"name": "bookingSubmit", "context": {"formId": "booking"}}}), + ], +) + + +async def build_form(state: MessagesState) -> dict: + """First-turn node: LLM authors the booking form.""" + base_messages = [SystemMessage(content=_BUILD_FORM_SYSTEM)] + state["messages"] + try: + spec = await _emit_with_retry(BookingFormSpec, base_messages) + except RuntimeError as err: + _logger.error("Falling back to sentinel booking form: %s", err) + spec = _SENTINEL_BOOKING_FORM + return {"messages": [AIMessage(content=_wrap_envelopes(spec))]} +``` + +- [ ] **Step 2: Verify module imports** + +Run: `cd cockpit/langgraph/streaming/python && uv run python -c "from src.a2ui_graph import build_form, _SENTINEL_BOOKING_FORM; print(len(_SENTINEL_BOOKING_FORM.components))"` +Expected output: `8` + +- [ ] **Step 3: Smoke the sentinel envelope output** + +Run: `cd cockpit/langgraph/streaming/python && uv run python -c " +from src.a2ui_graph import _SENTINEL_BOOKING_FORM, _wrap_envelopes, A2UI_PREFIX +out = _wrap_envelopes(_SENTINEL_BOOKING_FORM) +assert out.startswith(A2UI_PREFIX) +lines = out.strip().split('\n')[1:] +keys = [list(__import__('json').loads(l).keys())[0] for l in lines] +print('ENVELOPE_KEYS:', keys) +"` +Expected output: `ENVELOPE_KEYS: ['createSurface', 'updateDataModel', 'updateComponents']` + +- [ ] **Step 4: Commit** + +```bash +git add cockpit/langgraph/streaming/python/src/a2ui_graph.py +git commit -m "feat(c-a2ui): build_form node — LLM-authored booking form + sentinel fallback" +``` + +--- + +## Task 4: search_flights node + +**Files:** +- Modify: `cockpit/langgraph/streaming/python/src/a2ui_graph.py` (append below build_form) + +- [ ] **Step 1: Append search_flights node + sentinel fallback** + +Append to `cockpit/langgraph/streaming/python/src/a2ui_graph.py`: + +```python + + +# ── search_flights node ───────────────────────────────────────────────────── + +_SEARCH_FLIGHTS_SYSTEM = """You just received a booking submission. The find_routes() tool returned the following flights: + +{flights_json} + +Form data (for context): {form_json} + +Emit an A2UI results surface using the FlightResultsSpec schema. + +- surface_id MUST be "results" +- data_model can be {{}} (no user input needed on this surface) +- Root is a Column with children = list of flight Card ids (or a single Card "no_flights" if the list is empty) +- For each flight: a Card with title " flight ", containing TextField/Divider children showing route, depart/arrive times, duration, aircraft, gate, and a "Select" Button whose action emits {{"event": {{"name": "flightSelect", "context": {{"flightId": ""}}}}}} +- For the empty case: a single Card with id "no_flights" titled "No flights found", containing a "Modify search" Button with action {{"event": {{"name": "modifySearch", "context": {{"formId": "booking"}}}}}} + +Use unique `id` values for every component.""" + + +_SENTINEL_RESULTS = FlightResultsSpec( + surface_id="results", + data_model={}, + components=[ + A2uiComponent(id="root", component="Column", children=["msg"]), + A2uiComponent(id="msg", component="Card", title="Results unavailable", + children=["modify"]), + A2uiComponent(id="modify", component="Button", label="Modify search", + action={"event": {"name": "modifySearch", "context": {"formId": "booking"}}}), + ], +) + + +def _parse_submit_payload(content: str) -> dict[str, Any] | None: + """Extract the form-data dict from an a2ui_event message content.""" + try: + payload = json.loads(content) + except (json.JSONDecodeError, TypeError): + return None + if not isinstance(payload, dict) or payload.get("type") != "a2ui_event": + return None + # Accept either {"data": {...}} or {"value": {...}} or context-level fields + data = payload.get("data") or payload.get("value") or {} + if not isinstance(data, dict): + return None + return data + + +async def search_flights(state: MessagesState) -> dict: + """Post-submit node: call find_routes, emit results A2UI surface.""" + last = state["messages"][-1] + form_data = _parse_submit_payload(getattr(last, "content", "")) or {} + origin = (form_data.get("origin") or "").upper() + dest = (form_data.get("dest") or "").upper() + flights: list[dict[str, Any]] = [] + if origin and dest and origin != dest: + try: + flights = await find_routes.ainvoke({"from_code": origin, "to_code": dest}) + except Exception as err: # noqa: BLE001 — demo robustness + _logger.warning("find_routes failed for %s→%s: %s", origin, dest, err) + + base_messages = [ + SystemMessage(content=_SEARCH_FLIGHTS_SYSTEM.format( + flights_json=json.dumps(flights, indent=2), + form_json=json.dumps(form_data, indent=2), + )), + HumanMessage(content=f"Emit the results surface for {origin}→{dest}."), + ] + try: + spec = await _emit_with_retry(FlightResultsSpec, base_messages) + except RuntimeError as err: + _logger.error("Falling back to sentinel results surface: %s", err) + spec = _SENTINEL_RESULTS + return {"messages": [AIMessage(content=_wrap_envelopes(spec))]} +``` + +- [ ] **Step 2: Smoke parse_submit_payload** + +Run: `cd cockpit/langgraph/streaming/python && uv run python -c " +from src.a2ui_graph import _parse_submit_payload +import json +ok = _parse_submit_payload(json.dumps({'type':'a2ui_event','data':{'origin':'LAX','dest':'JFK'}})) +print('PARSED:', ok) +"` +Expected output: `PARSED: {'origin': 'LAX', 'dest': 'JFK'}` + +- [ ] **Step 3: Commit** + +```bash +git add cockpit/langgraph/streaming/python/src/a2ui_graph.py +git commit -m "feat(c-a2ui): search_flights node — LLM-authored results surface" +``` + +--- + +## Task 5: route node + graph compile + +**Files:** +- Modify: `cockpit/langgraph/streaming/python/src/a2ui_graph.py` (append below search_flights) + +- [ ] **Step 1: Append route + graph compile** + +Append to `cockpit/langgraph/streaming/python/src/a2ui_graph.py`: + +```python + + +# ── Routing + compile ─────────────────────────────────────────────────────── + +def _is_submit_event(content: str) -> bool: + """True iff the content is an a2ui_event whose formId is 'booking'.""" + try: + payload = json.loads(content) + except (json.JSONDecodeError, TypeError): + return False + return ( + isinstance(payload, dict) + and payload.get("type") == "a2ui_event" + and isinstance(payload.get("context"), dict) + and payload["context"].get("formId") == "booking" + ) + + +def route(state: MessagesState) -> Command[Literal["build_form", "search_flights"]]: + """Inspect the last message — submit event → search_flights, else build_form.""" + last_content = getattr(state["messages"][-1], "content", "") if state["messages"] else "" + if _is_submit_event(last_content): + return Command(goto="search_flights") + return Command(goto="build_form") + + +_builder = StateGraph(MessagesState) +_builder.add_node("route", route) +_builder.add_node("build_form", build_form) +_builder.add_node("search_flights", search_flights) +_builder.set_entry_point("route") +_builder.add_edge("build_form", END) +_builder.add_edge("search_flights", END) + +graph = _builder.compile() +``` + +- [ ] **Step 2: Verify graph compiles + routes correctly** + +Run: `cd cockpit/langgraph/streaming/python && uv run python -c " +from src.a2ui_graph import graph, _is_submit_event +import json +print('TYPE:', type(graph).__name__) +print('SUBMIT_TRUE:', _is_submit_event(json.dumps({'type':'a2ui_event','context':{'formId':'booking'}}))) +print('SUBMIT_FALSE:', _is_submit_event('hello')) +"` +Expected output: +``` +TYPE: CompiledStateGraph +SUBMIT_TRUE: True +SUBMIT_FALSE: False +``` + +- [ ] **Step 3: Commit** + +```bash +git add cockpit/langgraph/streaming/python/src/a2ui_graph.py +git commit -m "feat(c-a2ui): route node + 3-node graph compile" +``` + +--- + +## Task 6: End-to-end real-LLM smoke (umbrella) + +**Files:** +- Read-only: repo root `.env` for `OPENAI_API_KEY` + +- [ ] **Step 1: Confirm key present** + +Run from repo root: `grep -q '^OPENAI_API_KEY=' .env && echo found` +Expected: `found` + +- [ ] **Step 2: Smoke build_form via the graph** + +Run from repo root: +```bash +set -a; source .env; set +a +cd cockpit/langgraph/streaming/python && uv run python -c " +import asyncio, json +from src.a2ui_graph import graph, A2UI_PREFIX +from langchain_core.messages import HumanMessage + +async def main(): + result = await graph.ainvoke({'messages': [HumanMessage(content='I want to fly somewhere')]}) + out = result['messages'][-1].content + assert out.startswith(A2UI_PREFIX), f'missing prefix, got: {out[:80]}' + lines = out.strip().split('\n')[1:] + envelopes = [json.loads(l) for l in lines] + keys = [list(e.keys())[0] for e in envelopes] + print('ENVELOPE_KEYS:', keys) + components = envelopes[2]['updateComponents']['components'] + types = sorted({c['component'] for c in components}) + print('COMPONENT_TYPES:', types) + print('SURFACE_ID:', envelopes[0]['createSurface']['surfaceId']) + +asyncio.run(main()) +" +``` +Expected: `ENVELOPE_KEYS: ['createSurface', 'updateDataModel', 'updateComponents']`; `COMPONENT_TYPES` includes at least `Button`, `Card`, `ChoicePicker`, `Column`, `NumberField`, `TextField`; `SURFACE_ID: booking`. + +- [ ] **Step 3: Smoke search_flights via the graph** + +Run from repo root: +```bash +set -a; source .env; set +a +cd cockpit/langgraph/streaming/python && uv run python -c " +import asyncio, json +from src.a2ui_graph import graph, A2UI_PREFIX +from langchain_core.messages import HumanMessage + +async def main(): + submit = json.dumps({ + 'type': 'a2ui_event', + 'context': {'formId': 'booking'}, + 'data': {'origin': 'LAX', 'dest': 'JFK', 'date': '2026-06-15', 'passengers': 1, 'fare_class': 'Economy'}, + }) + result = await graph.ainvoke({'messages': [HumanMessage(content=submit)]}) + out = result['messages'][-1].content + assert out.startswith(A2UI_PREFIX), f'missing prefix, got: {out[:80]}' + lines = out.strip().split('\n')[1:] + envelopes = [json.loads(l) for l in lines] + keys = [list(e.keys())[0] for e in envelopes] + print('ENVELOPE_KEYS:', keys) + print('SURFACE_ID:', envelopes[0]['createSurface']['surfaceId']) + components = envelopes[2]['updateComponents']['components'] + print('N_COMPONENTS:', len(components)) + +asyncio.run(main()) +" +``` +Expected: `ENVELOPE_KEYS: ['createSurface', 'updateDataModel', 'updateComponents']`; `SURFACE_ID: results`; `N_COMPONENTS >= 2`. + +- [ ] **Step 4: No commit unless smoke revealed a bug** + +If both smokes pass: nothing to commit. If they revealed an issue, fix the relevant earlier task's code, re-run Step 2/3, then: + +```bash +git add cockpit/langgraph/streaming/python/src/a2ui_graph.py +git commit -m "fix(c-a2ui): smoke fixes for end-to-end run" +``` + +--- + +## Task 7: Mirror to standalone + +**Files:** +- Modify: `cockpit/chat/a2ui/python/src/graph.py` (full replacement — mirror of umbrella `a2ui_graph.py` minus the `from src.aviation_tools import find_routes` line, which doesn't exist in the standalone) + +- [ ] **Step 1: Verify standalone has no aviation_tools** + +Run: `ls cockpit/chat/a2ui/python/src/ 2>&1` +Expected: shows `graph.py`, `index.ts` (and `__pycache__`). NO `aviation_tools.py`. + +- [ ] **Step 2: Inline find_routes minimally into standalone** + +Replace `cockpit/chat/a2ui/python/src/graph.py` with the entire contents of `cockpit/langgraph/streaming/python/src/a2ui_graph.py` BUT: + +1. Replace this import: +```python +from src.aviation_tools import find_routes # noqa: E402 +``` +with this inline data + helper (same 4-airline / per-route shape as `aviation_data.py`'s FLIGHTS list, abbreviated to keep standalone self-contained): + +```python +# Inlined flight fixtures — standalone has no aviation_data module. +_FLIGHTS = [ + {"flight_number": "UA123", "airline": "UA", "from": "LAX", "to": "JFK", + "depart_local": "08:00", "arrive_local": "16:30", "duration_min": 330, + "status": "on_time", "gate": "B14", "aircraft": "Boeing 787"}, + {"flight_number": "AA456", "airline": "AA", "from": "JFK", "to": "LAX", + "depart_local": "10:00", "arrive_local": "13:30", "duration_min": 390, + "status": "on_time", "gate": "T5-22", "aircraft": "Boeing 777"}, + {"flight_number": "DL789", "airline": "DL", "from": "ATL", "to": "ORD", + "depart_local": "07:15", "arrive_local": "08:45", "duration_min": 150, + "status": "delayed", "gate": "A12", "aircraft": "Airbus A320"}, + {"flight_number": "B6101", "airline": "B6", "from": "BOS", "to": "MIA", + "depart_local": "06:30", "arrive_local": "10:15", "duration_min": 225, + "status": "on_time", "gate": "C8", "aircraft": "Airbus A321"}, + {"flight_number": "UA204", "airline": "UA", "from": "SFO", "to": "SEA", + "depart_local": "09:00", "arrive_local": "11:00", "duration_min": 120, + "status": "on_time", "gate": "F11", "aircraft": "Boeing 737"}, +] + + +class _AsyncFn: + """Tiny shim so we can call find_routes.ainvoke({...}) like the umbrella's + LangChain @tool decorator does.""" + def __init__(self, fn): + self._fn = fn + + async def ainvoke(self, args: dict[str, Any]) -> list[dict[str, Any]]: + return self._fn(**args) + + +def _find_routes_impl(from_code: str, to_code: str, date_offset_days: int = 0) -> list[dict[str, Any]]: + return [f for f in _FLIGHTS if f["from"] == from_code and f["to"] == to_code] + + +find_routes = _AsyncFn(_find_routes_impl) +``` + +The rest of the module (schemas, _wrap_envelopes, LLM, retry, build_form, search_flights, route, graph compile) is identical to the umbrella copy. + +- [ ] **Step 3: Verify standalone imports + graph compiles** + +Run: `cd cockpit/chat/a2ui/python && uv run python -c " +from src.graph import graph, find_routes +import asyncio +print('TYPE:', type(graph).__name__) +async def main(): + print('ROUTES_LAX_JFK:', await find_routes.ainvoke({'from_code':'LAX','to_code':'JFK'})) +asyncio.run(main()) +"` +Expected: `TYPE: CompiledStateGraph` and one flight dict in the list. + +- [ ] **Step 4: Commit** + +```bash +git add cockpit/chat/a2ui/python/src/graph.py +git commit -m "feat(c-a2ui standalone): mirror LLM-driven booking form + inlined flight fixtures" +``` + +--- + +## Task 8: Build verification + +**Files:** +- No code changes — pure check + +- [ ] **Step 1: Build umbrella langgraph python** + +Run from repo root: `pnpm nx run cockpit-langgraph-streaming-python:build` +Expected: green. + +- [ ] **Step 2: Build chat-a2ui Angular** + +Run from repo root: `pnpm nx run cockpit-chat-a2ui-angular:build` +Expected: green. (Frontend untouched — sanity check.) + +- [ ] **Step 3: No commit (verification only)** + +If either fails, fix the underlying task's file, re-run. + +--- + +## Task 9: REQUIRED — iterative chrome MCP smoke + +This task is iterative: send a prompt, observe behavior, fix any bug found in the relevant earlier task, restart backend, re-test. Do not skip. The user explicitly requested "use chrome MCP to iteratively verify until successful. Be exhaustive." + +**Files:** +- Read-only: `.env` for `OPENAI_API_KEY` +- Servers: start umbrella langgraph dev + cockpit chat-a2ui Angular dev + +- [ ] **Step 1: Start servers** + +In one background shell from repo root: +```bash +set -a; source .env; set +a +cd cockpit/langgraph/streaming/python && uv run langgraph dev --port 8123 --no-browser +``` + +In another background shell from repo root: +```bash +pnpm nx serve cockpit-chat-a2ui-angular --port 4201 +``` + +(If port 4200 is taken, use 4201 — PR 3's verification used 4201 already.) + +- [ ] **Step 2: Ensure dev env points to umbrella backend on /api** + +If `cockpit/chat/a2ui/angular/src/environments/environment.development.ts` doesn't already point at `/api` with assistantId `c-a2ui`, edit it locally (do NOT commit this — it's a local-only override matching PR 3's pattern). Confirm proxy.conf.json forwards `/api` → `localhost:8123`. + +- [ ] **Step 3: Drive the first prompt via chrome MCP** + +Use programmatic JS input (avoids the known Angular hydration race): +``` +mcp__Claude_in_Chrome__javascript_tool: + (()=>{const ta=document.querySelector('textarea'); + const setter=Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype,'value').set; + setter.call(ta,'I want to fly from LAX to JFK'); + ta.dispatchEvent(new Event('input',{bubbles:true})); + ta.focus(); + ta.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',code:'Enter',bubbles:true})); + return 'sent'})() +``` + +Then wait 30s. + +- [ ] **Step 4: Verify booking form rendered** + +Screenshot + DOM query: +``` +mcp__Claude_in_Chrome__javascript_tool: + JSON.stringify({ + surfaces: document.querySelectorAll('a2ui-surface, [data-surface-id]').length, + pickers: document.querySelectorAll('a2ui-choicepicker, [data-component="ChoicePicker"]').length, + textfields: document.querySelectorAll('a2ui-textfield, [data-component="TextField"]').length, + buttons: document.querySelectorAll('a2ui-button, [data-component="Button"]').length, + text: document.body.innerText.substring(0, 400), + }) +``` + +Expected: surfaces ≥ 1; at least 2 ChoicePickers (origin + dest + fare = 3 actually); at least 1 TextField (date) or 1 NumberField; at least 1 Button (submit); the text snapshot includes "Book a flight" or similar. + +If any expected element is missing or the surface didn't render at all: open backend `/threads` to inspect the actual JSONL produced, identify which envelope or component is malformed, fix the prompt or schema in the relevant Task (1–5), restart backend, retry Step 3. + +- [ ] **Step 5: Fill the form and submit** + +Inspect the rendered form to identify input element refs (use `mcp__Claude_in_Chrome__find` with queries like "origin airport picker"). For each field: +- Origin picker → click + select "LAX" +- Destination picker → click + select "JFK" +- Date TextField → click, type "2026-06-15" +- Fare class → select "Business" (or accept default) +- Submit button → click + +Approximate JS-driven path if mouse interactions are flaky: +``` +mcp__Claude_in_Chrome__javascript_tool: + // Find the submit button by label and click it after filling the data model + (()=>{ + // The render-element store is populated via path bindings; in the chat lib, + // form field changes go through the a2ui:datamodel:: emit protocol. + // For verification, just click the submit button and let the model send empty data. + const buttons = Array.from(document.querySelectorAll('button')); + const submit = buttons.find(b => /Search flights|Submit/i.test(b.textContent || '')); + if (submit) { submit.click(); return 'clicked'; } + return 'no-submit-found'; + })() +``` + +Wait 30s. + +- [ ] **Step 6: Verify results surface rendered** + +``` +mcp__Claude_in_Chrome__javascript_tool: + JSON.stringify({ + cards: document.querySelectorAll('a2ui-card, [data-component="Card"]').length, + selectButtons: Array.from(document.querySelectorAll('button')).filter(b => /Select/.test(b.textContent || '')).length, + text: document.body.innerText.substring(0, 600), + }) +``` + +Expected (LAX→JFK, with `aviation_data.FLIGHTS` containing UA123 LAX→JFK): cards ≥ 1; selectButtons ≥ 1; text includes "UA123" or another flight number. + +If results didn't render or `find_routes` returned empty when it shouldn't have, debug: +- Check backend log: did `search_flights` run? Did the submit payload parse? +- Check the LLM output: was it valid FlightResultsSpec or did the retry exhaust? + +Fix the relevant earlier task, restart backend, redo Step 5/6. + +- [ ] **Step 7: Capture before/after screenshots for the PR** + +Use `mcp__Claude_in_Chrome__computer` action `screenshot` with `save_to_disk: true`. Attach these to the PR body in Task 11. + +- [ ] **Step 8: Stop servers** when done. + +--- + +## Task 10: Final integration smoke (post-iteration) + +**Files:** +- No code changes + +- [ ] **Step 1: Re-run umbrella `:build`** + +Run: `pnpm nx run cockpit-langgraph-streaming-python:build` +Expected: green. + +- [ ] **Step 2: Re-run all-examples build (lighter version: chat-a2ui only)** + +Run: `pnpm nx run cockpit-chat-a2ui-angular:build` +Expected: green. + +If clean, no commit. + +--- + +## Task 11: Open the PR + +- [ ] **Step 1: Push branch** + +Run: `git push -u origin HEAD` + +- [ ] **Step 2: Open PR** + +Run: +```bash +gh pr create --title "feat(c-a2ui): LLM-driven aviation booking form (PR 4 of 4)" --body "$(cat <<'EOF' +## Summary +- PR 4 of 4 — final piece of the c-* aviation theme rollout. +- Replaces the hardcoded contact-form JSONL in \`a2ui_graph.py\` with an **LLM-authored** aviation booking form, plus an **LLM-authored** post-submit flight-results surface. +- LLM output is constrained by Pydantic schemas (\`A2uiComponent\` + \`BookingFormSpec\` / \`FlightResultsSpec\`) via \`.with_structured_output()\`. On validation failure: 2 retries with the error re-injected, then a hardcoded sentinel form so the demo doesn't crash. +- Disproves the prior code's claim that "LLMs cannot reliably emit A2UI JSONL" — schema-constrained structured output works. + +## Architecture +\`route → {build_form | search_flights} → END\`. \`build_form\` runs on first turn; \`search_flights\` runs when the last message is an a2ui_event with formId='booking' and calls \`find_routes()\` (PR 1's aviation_tools) before authoring the results surface. + +LLM: \`gpt-5\` with \`reasoning_effort='low'\` (matches PR #372's tool-discipline pattern). + +## Test plan +- [x] \`pnpm nx run cockpit-langgraph-streaming-python:build\` — green +- [x] \`pnpm nx run cockpit-chat-a2ui-angular:build\` — green +- [x] Programmatic real-LLM smoke (umbrella): + - \`build_form\` → 3 envelopes (createSurface/updateDataModel/updateComponents), surface_id 'booking', all required component types present + - \`search_flights\` for LAX→JFK submit → 3 envelopes, surface_id 'results', N≥2 components +- [x] Manual chrome MCP smoke: + - "I want to fly from LAX to JFK" → booking form renders with origin/dest pickers, date field, passenger field, fare-class picker, gated submit + - Submit LAX→JFK → results surface with at least one flight Card +- [ ] CI + +## Files +- \`cockpit/langgraph/streaming/python/src/a2ui_graph.py\` — full rewrite (3 nodes, Pydantic schemas, retry, find_routes integration) +- \`cockpit/chat/a2ui/python/src/graph.py\` — mirror with inlined flight fixtures + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +- [ ] **Step 3: Watch CI** + +Run: `gh pr checks --watch` + +- [ ] **Step 4: Squash-merge on green** + +Run: `gh pr merge --squash` + +--- + +## Self-Review + +**Spec coverage:** +- Decision 1 (LLM emits via structured output with retry) → Tasks 2, 3, 4 ✓ +- Decision 2 (post-submit emits 2nd A2UI surface) → Task 4 ✓ +- Decision 3 (2 retries + sentinel fallback) → Task 2 helper + Tasks 3, 4 sentinels ✓ +- Decision 4 (gpt-5 + reasoning_effort='low') → Task 2 ✓ +- Pydantic schemas (BookingFormSpec, FlightResultsSpec, A2uiComponent) → Task 1 ✓ +- Envelope wrapping with deterministic 3-envelope sequence → Task 1 `_wrap_envelopes` ✓ +- Aviation form fields (origin, dest, date, passengers, fare class, submit) → Task 3 prompt ✓ +- find_routes integration → Task 4 ✓ +- Sentinel fallback for both surfaces → Tasks 3, 4 ✓ +- Standalone mirror → Task 7 ✓ +- Chrome MCP iterative verification → Task 9 ✓ + +**Placeholder scan:** No TBDs. Every code step includes full code. Task 7 explicitly references "the rest of the module is identical to the umbrella copy" — acceptable because the umbrella file is fully specified in Tasks 1-5 and the only diff is the `find_routes` import substitution. + +**Type consistency:** +- `BookingFormSpec` / `FlightResultsSpec` extend `_SurfaceSpec` consistently +- `A2uiComponent.component` Literal list (`Column, Row, Card, TextField, ChoicePicker, NumberField, DatePicker, CheckBox, Button, Divider`) matches what build_form prompt asks for and what search_flights prompt asks for +- `_emit_with_retry(spec_cls, base_messages, max_attempts=3)` signature consistent across Tasks 3, 4 +- `_wrap_envelopes(spec)` signature consistent across Tasks 3, 4 +- `find_routes.ainvoke({"from_code", "to_code"})` matches the @tool signature in `aviation_tools.py` (verified against PR 1) From 3e0f6737313875d612c3f259ebe15f9508e68c3b Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 16 May 2026 14:20:17 -0700 Subject: [PATCH 03/12] feat(c-a2ui): pydantic schemas + envelope wrapper (foundation) --- .../streaming/python/src/a2ui_graph.py | 386 ++++++++++++++---- 1 file changed, 302 insertions(+), 84 deletions(-) diff --git a/cockpit/langgraph/streaming/python/src/a2ui_graph.py b/cockpit/langgraph/streaming/python/src/a2ui_graph.py index 30a571169..30b952ae0 100644 --- a/cockpit/langgraph/streaming/python/src/a2ui_graph.py +++ b/cockpit/langgraph/streaming/python/src/a2ui_graph.py @@ -1,104 +1,322 @@ """ -A2UI Contact Form Graph +A2UI Aviation Booking Form Graph -Demonstrates the A2UI (Agent-to-UI) protocol by emitting hardcoded JSONL -that builds an interactive contact form on the Angular frontend. -Uses the v0.9 envelope format: {"createSurface": {...}}. +LLM-authored A2UI surfaces: +- build_form: emits a flight-booking form via gpt-5 with structured output +- search_flights: post-submit, calls find_routes() and emits a results surface -The graph does NOT use an LLM for UI generation — A2UI JSONL requires -exact format adherence that LLMs cannot reliably provide. The LLM is -only used for conversational responses to form submission events. +Both surfaces are constrained by Pydantic schemas (A2uiComponent + +BookingFormSpec / FlightResultsSpec) and validated; on ValidationError, +the LLM is re-prompted with the error up to 2 retries. After 3 total +attempts, a hardcoded sentinel form is emitted so the demo doesn't 500. + +This replaces the prior hardcoded contact-form implementation. The +prior file's claim that "LLMs cannot reliably emit A2UI JSONL" is +disproven by schema-constrained structured output — the LLM authors +the components list, code wraps it in the deterministic envelope keys. """ import json +import logging +from typing import Any, Literal + +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage +from langchain_openai import ChatOpenAI from langgraph.graph import StateGraph, MessagesState, END -from langchain_core.messages import AIMessage +from langgraph.types import Command +from pydantic import BaseModel, Field, ValidationError + +from src.aviation_tools import find_routes # noqa: E402 + +_logger = logging.getLogger(__name__) A2UI_PREFIX = "---a2ui_JSON---" -# v0.9 envelope format: each message is {"": {}} -CONTACT_FORM_JSONL = A2UI_PREFIX + "\n" + "\n".join([ - json.dumps({"createSurface": { - "surfaceId": "contact", "catalogId": "basic", "sendDataModel": True, - }}), - json.dumps({"updateDataModel": { - "surfaceId": "contact", - "value": {"name": "", "email": "", "department": "Engineering", "consent": False}, - }}), - json.dumps({"updateComponents": { - "surfaceId": "contact", - "components": [ - {"id": "root", "component": "Column", "children": ["card"]}, - {"id": "card", "component": "Card", "title": "Contact Us", "children": [ - "name_field", "email_field", "dept_picker", "consent_check", "divider", "submit_btn", - ]}, - {"id": "name_field", "component": "TextField", - "label": "Name", "value": {"path": "/name"}, "placeholder": "Your full name", - "checks": [ - {"condition": {"call": "required", "args": {"value": {"path": "/name"}}}, - "message": "Name is required"}, - ]}, - {"id": "email_field", "component": "TextField", - "label": "Email", "value": {"path": "/email"}, "placeholder": "you@company.com", - "checks": [ - {"condition": {"call": "required", "args": {"value": {"path": "/email"}}}, - "message": "Email is required"}, - {"condition": {"call": "email", "args": {"value": {"path": "/email"}}}, - "message": "Must be a valid email address"}, - ]}, - {"id": "dept_picker", "component": "ChoicePicker", - "label": "Department", - "options": ["Engineering", "Sales", "Support", "Marketing"], - "selected": {"path": "/department"}}, - {"id": "consent_check", "component": "CheckBox", - "label": "I agree to be contacted", "checked": {"path": "/consent"}}, - {"id": "divider", "component": "Divider"}, - {"id": "submit_btn", "component": "Button", - "label": "Submit", - "checks": [ - {"condition": {"call": "and", "args": {"values": [ - {"call": "required", "args": {"value": {"path": "/name"}}}, - {"call": "email", "args": {"value": {"path": "/email"}}}, - {"path": "/consent"}, - ]}}, - "message": "Complete all required fields and agree to be contacted"}, - ], - "action": {"event": {"name": "formSubmit", "context": {"formId": "contact"}}}}, - ], - }}), -]) + "\n" # Trailing newline required — parser processes at \n boundaries - - -def build_a2ui_graph(): +# 10 IATA airports from aviation_data.py +AIRPORT_CODES = ["LAX", "JFK", "SFO", "ORD", "BOS", "ATL", "DFW", "SEA", "MIA", "DEN"] +FARE_CLASSES = ["Economy", "Premium", "Business", "First"] + + +# ── Pydantic schemas ──────────────────────────────────────────────────────── + +class A2uiComponent(BaseModel): + """Single A2UI updateComponents entry. + + Literal[...] on `component` is the gate that keeps the LLM from + inventing component types not in the catalog. Pydantic raises + ValidationError if it does, triggering retry. """ - Single-node graph: - - On first message: emits hardcoded A2UI JSONL for the contact form - - On a2ui_event messages: responds with a confirmation message + id: str + component: Literal[ + "Column", "Row", "Card", "TextField", "ChoicePicker", + "NumberField", "DatePicker", "CheckBox", "Button", "Divider", + ] + label: str | None = None + title: str | None = None + placeholder: str | None = None + options: list[str] | None = None + value: dict[str, Any] | None = None + selected: dict[str, Any] | None = None + checked: dict[str, Any] | None = None + children: list[str] | None = None + checks: list[dict[str, Any]] | None = None + action: dict[str, Any] | None = None + + +class _SurfaceSpec(BaseModel): + """Common shape — both booking and results surfaces produce the same + triple (surface_id, data_model, components).""" + surface_id: str = Field(description="Surface id. Use 'booking' for the form, 'results' for flights.") + data_model: dict[str, Any] = Field(description="Initial form/state values, e.g. prefills.") + components: list[A2uiComponent] + + +class BookingFormSpec(_SurfaceSpec): + pass + + +class FlightResultsSpec(_SurfaceSpec): + pass + + +# ── Envelope wrapping ─────────────────────────────────────────────────────── + +def _wrap_envelopes(spec: _SurfaceSpec) -> str: + """Wrap a validated SurfaceSpec into A2UI JSONL with the three required envelopes.""" + lines = [ + json.dumps({"createSurface": { + "surfaceId": spec.surface_id, + "catalogId": "basic", + "sendDataModel": True, + }}), + json.dumps({"updateDataModel": { + "surfaceId": spec.surface_id, + "value": spec.data_model, + }}), + json.dumps({"updateComponents": { + "surfaceId": spec.surface_id, + "components": [c.model_dump(exclude_none=True) for c in spec.components], + }}), + ] + return A2UI_PREFIX + "\n" + "\n".join(lines) + "\n" + + +# ── LLM + retry ───────────────────────────────────────────────────────────── + +# gpt-5 with low reasoning effort: PR #372 established gpt-5 follows +# directive precisely; "low" gives slightly more headroom than "minimal" +# for schema compliance. +_llm: ChatOpenAI | None = None + + +def _get_llm() -> ChatOpenAI: + """Lazy-initialize the LLM so imports succeed without OPENAI_API_KEY set.""" + global _llm + if _llm is None: + _llm = ChatOpenAI( + model="gpt-5", + streaming=True, + reasoning_effort="low", + ) + return _llm + + +async def _emit_with_retry( + spec_cls: type[_SurfaceSpec], + base_messages: list[Any], + max_attempts: int = 3, +) -> _SurfaceSpec: + """Call the LLM with structured output, retrying on validation failure. + + Each retry re-injects the error message so the model has a chance + to correct its output. After max_attempts, raises RuntimeError. """ + llm = _get_llm().with_structured_output(spec_cls) + messages = list(base_messages) + last_err: Exception | None = None + for attempt in range(max_attempts): + try: + return await llm.ainvoke(messages) + except ValidationError as err: + last_err = err + _logger.warning( + "A2UI structured-output validation failed (attempt %d/%d): %s", + attempt + 1, max_attempts, err, + ) + messages = list(base_messages) + [ + AIMessage(content=( + f"Previous attempt failed schema validation: {err}. " + "Try again, strictly matching the schema. " + "Do not invent component types outside the Literal list." + )), + ] + raise RuntimeError( + f"LLM failed structured output after {max_attempts} attempts: {last_err}" + ) + - async def create_form(state: MessagesState) -> dict: - last = state["messages"][-1] +# ── build_form node ───────────────────────────────────────────────────────── - # Check if this is a form submission event from the A2UI surface +_BUILD_FORM_SYSTEM = f"""You are an aviation booking-form designer. Emit an A2UI booking form using the structured output schema. + +Required components (in this order, inside a single Card titled "Book a flight" inside a Column root): +1. Origin airport ChoicePicker — options = {AIRPORT_CODES}, selected = {{"path": "/origin"}} +2. Destination airport ChoicePicker — same options, selected = {{"path": "/dest"}} +3. Departure date TextField — value = {{"path": "/date"}}, placeholder "YYYY-MM-DD" +4. Passengers NumberField — value = {{"path": "/passengers"}} +5. Fare class ChoicePicker — options = {FARE_CLASSES}, selected = {{"path": "/fare_class"}} +6. Submit Button — label "Search flights", action = {{"event": {{"name": "bookingSubmit", "context": {{"formId": "booking"}}}}}} + +Submit button MUST be gated by `checks` that require: +- /origin set (call: "required") +- /dest set (call: "required") +- /date set (call: "required") + +Default data_model: {{"origin": "", "dest": "", "date": "", "passengers": 1, "fare_class": "Economy"}} +surface_id MUST be "booking". + +Use unique `id` values for every component (e.g. "root", "card", "origin_field", etc.).""" + + +_SENTINEL_BOOKING_FORM = BookingFormSpec( + surface_id="booking", + data_model={"origin": "", "dest": "", "date": "", "passengers": 1, "fare_class": "Economy"}, + components=[ + A2uiComponent(id="root", component="Column", children=["card"]), + A2uiComponent(id="card", component="Card", title="Book a flight (fallback)", + children=["origin", "dest", "date", "passengers", "fare", "submit"]), + A2uiComponent(id="origin", component="ChoicePicker", label="Origin", + options=AIRPORT_CODES, selected={"path": "/origin"}), + A2uiComponent(id="dest", component="ChoicePicker", label="Destination", + options=AIRPORT_CODES, selected={"path": "/dest"}), + A2uiComponent(id="date", component="TextField", label="Date", + value={"path": "/date"}, placeholder="YYYY-MM-DD"), + A2uiComponent(id="passengers", component="NumberField", label="Passengers", + value={"path": "/passengers"}), + A2uiComponent(id="fare", component="ChoicePicker", label="Fare class", + options=FARE_CLASSES, selected={"path": "/fare_class"}), + A2uiComponent(id="submit", component="Button", label="Search flights", + action={"event": {"name": "bookingSubmit", "context": {"formId": "booking"}}}), + ], +) + + +async def build_form(state: MessagesState) -> dict: + """First-turn node: LLM authors the booking form.""" + base_messages = [SystemMessage(content=_BUILD_FORM_SYSTEM)] + state["messages"] + try: + spec = await _emit_with_retry(BookingFormSpec, base_messages) + except RuntimeError as err: + _logger.error("Falling back to sentinel booking form: %s", err) + spec = _SENTINEL_BOOKING_FORM + return {"messages": [AIMessage(content=_wrap_envelopes(spec))]} + + +# ── search_flights node ───────────────────────────────────────────────────── + +_SEARCH_FLIGHTS_SYSTEM = """You just received a booking submission. The find_routes() tool returned the following flights: + +{flights_json} + +Form data (for context): {form_json} + +Emit an A2UI results surface using the FlightResultsSpec schema. + +- surface_id MUST be "results" +- data_model can be {{}} (no user input needed on this surface) +- Root is a Column with children = list of flight Card ids (or a single Card "no_flights" if the list is empty) +- For each flight: a Card with title " flight ", containing TextField/Divider children showing route, depart/arrive times, duration, aircraft, gate, and a "Select" Button whose action emits {{"event": {{"name": "flightSelect", "context": {{"flightId": ""}}}}}} +- For the empty case: a single Card with id "no_flights" titled "No flights found", containing a "Modify search" Button with action {{"event": {{"name": "modifySearch", "context": {{"formId": "booking"}}}}}} + +Use unique `id` values for every component.""" + + +_SENTINEL_RESULTS = FlightResultsSpec( + surface_id="results", + data_model={}, + components=[ + A2uiComponent(id="root", component="Column", children=["msg"]), + A2uiComponent(id="msg", component="Card", title="Results unavailable", + children=["modify"]), + A2uiComponent(id="modify", component="Button", label="Modify search", + action={"event": {"name": "modifySearch", "context": {"formId": "booking"}}}), + ], +) + + +def _parse_submit_payload(content: str) -> dict[str, Any] | None: + """Extract the form-data dict from an a2ui_event message content.""" + try: + payload = json.loads(content) + except (json.JSONDecodeError, TypeError): + return None + if not isinstance(payload, dict) or payload.get("type") != "a2ui_event": + return None + # Accept either {"data": {...}} or {"value": {...}} or context-level fields + data = payload.get("data") or payload.get("value") or {} + if not isinstance(data, dict): + return None + return data + + +async def search_flights(state: MessagesState) -> dict: + """Post-submit node: call find_routes, emit results A2UI surface.""" + last = state["messages"][-1] + form_data = _parse_submit_payload(getattr(last, "content", "")) or {} + origin = (form_data.get("origin") or "").upper() + dest = (form_data.get("dest") or "").upper() + flights: list[dict[str, Any]] = [] + if origin and dest and origin != dest: try: - payload = json.loads(last.content) - if isinstance(payload, dict) and payload.get("type") == "a2ui_event": - name = payload.get("context", {}).get("formId", "unknown") - return {"messages": [AIMessage( - content=f"Thanks for submitting the **{name}** form! We'll be in touch soon.", - )]} - except (json.JSONDecodeError, AttributeError): - pass + flights = await find_routes.ainvoke({"from_code": origin, "to_code": dest}) + except Exception as err: # noqa: BLE001 — demo robustness + _logger.warning("find_routes failed for %s→%s: %s", origin, dest, err) + + base_messages = [ + SystemMessage(content=_SEARCH_FLIGHTS_SYSTEM.format( + flights_json=json.dumps(flights, indent=2), + form_json=json.dumps(form_data, indent=2), + )), + HumanMessage(content=f"Emit the results surface for {origin}→{dest}."), + ] + try: + spec = await _emit_with_retry(FlightResultsSpec, base_messages) + except RuntimeError as err: + _logger.error("Falling back to sentinel results surface: %s", err) + spec = _SENTINEL_RESULTS + return {"messages": [AIMessage(content=_wrap_envelopes(spec))]} + + +# ── Routing + compile ─────────────────────────────────────────────────────── + +def _is_submit_event(content: str) -> bool: + """True iff the content is an a2ui_event whose formId is 'booking'.""" + try: + payload = json.loads(content) + except (json.JSONDecodeError, TypeError): + return False + return ( + isinstance(payload, dict) + and payload.get("type") == "a2ui_event" + and isinstance(payload.get("context"), dict) + and payload["context"].get("formId") == "booking" + ) - # Any other message — emit the contact form - return {"messages": [AIMessage(content=CONTACT_FORM_JSONL)]} - graph = StateGraph(MessagesState) - graph.add_node("create_form", create_form) - graph.set_entry_point("create_form") - graph.add_edge("create_form", END) +def route(state: MessagesState) -> Command[Literal["build_form", "search_flights"]]: + """Inspect the last message — submit event → search_flights, else build_form.""" + last_content = getattr(state["messages"][-1], "content", "") if state["messages"] else "" + if _is_submit_event(last_content): + return Command(goto="search_flights") + return Command(goto="build_form") - return graph.compile() +_builder = StateGraph(MessagesState) +_builder.add_node("route", route) +_builder.add_node("build_form", build_form) +_builder.add_node("search_flights", search_flights) +_builder.set_entry_point("route") +_builder.add_edge("build_form", END) +_builder.add_edge("search_flights", END) -graph = build_a2ui_graph() +graph = _builder.compile() From 06080f47443988e68a111e4ec823f4f575350a5c Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 16 May 2026 14:28:13 -0700 Subject: [PATCH 04/12] feat(c-a2ui): gpt-5 structured-output LLM + retry helper From f89cda88a720e16f9899cc9b24093a69b6a1fd1e Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 16 May 2026 14:32:07 -0700 Subject: [PATCH 05/12] =?UTF-8?q?feat(c-a2ui):=20build=5Fform=20node=20?= =?UTF-8?q?=E2=80=94=20LLM-authored=20booking=20form=20+=20sentinel=20fall?= =?UTF-8?q?back?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 9a9599586e89ff4934b3dbe035b43bb8c93c4604 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 16 May 2026 14:36:21 -0700 Subject: [PATCH 06/12] =?UTF-8?q?feat(c-a2ui):=20search=5Fflights=20node?= =?UTF-8?q?=20=E2=80=94=20LLM-authored=20results=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 3bbae3a9926af826f5a06b75f3f619125df8310a Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 16 May 2026 14:36:21 -0700 Subject: [PATCH 07/12] feat(c-a2ui): route node + 3-node graph compile From 5c1fec916d2b917d80a8e3a5ae0388ac4ed8502f Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 16 May 2026 14:42:44 -0700 Subject: [PATCH 08/12] feat(c-a2ui standalone): mirror LLM-driven booking form + inlined flight fixtures --- cockpit/chat/a2ui/python/src/graph.py | 420 ++++++++++++++++++++------ 1 file changed, 336 insertions(+), 84 deletions(-) diff --git a/cockpit/chat/a2ui/python/src/graph.py b/cockpit/chat/a2ui/python/src/graph.py index 30a571169..05d635e97 100644 --- a/cockpit/chat/a2ui/python/src/graph.py +++ b/cockpit/chat/a2ui/python/src/graph.py @@ -1,104 +1,356 @@ """ -A2UI Contact Form Graph +A2UI Aviation Booking Form Graph -Demonstrates the A2UI (Agent-to-UI) protocol by emitting hardcoded JSONL -that builds an interactive contact form on the Angular frontend. -Uses the v0.9 envelope format: {"createSurface": {...}}. +LLM-authored A2UI surfaces: +- build_form: emits a flight-booking form via gpt-5 with structured output +- search_flights: post-submit, calls find_routes() and emits a results surface -The graph does NOT use an LLM for UI generation — A2UI JSONL requires -exact format adherence that LLMs cannot reliably provide. The LLM is -only used for conversational responses to form submission events. +Both surfaces are constrained by Pydantic schemas (A2uiComponent + +BookingFormSpec / FlightResultsSpec) and validated; on ValidationError, +the LLM is re-prompted with the error up to 2 retries. After 3 total +attempts, a hardcoded sentinel form is emitted so the demo doesn't 500. + +This replaces the prior hardcoded contact-form implementation. The +prior file's claim that "LLMs cannot reliably emit A2UI JSONL" is +disproven by schema-constrained structured output — the LLM authors +the components list, code wraps it in the deterministic envelope keys. """ import json +import logging +from typing import Any, Literal + +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage +from langchain_openai import ChatOpenAI from langgraph.graph import StateGraph, MessagesState, END -from langchain_core.messages import AIMessage +from langgraph.types import Command +from pydantic import BaseModel, Field, ValidationError + +# Inlined flight fixtures — standalone has no aviation_data module. +_FLIGHTS = [ + {"flight_number": "UA123", "airline": "UA", "from": "LAX", "to": "JFK", + "depart_local": "08:00", "arrive_local": "16:30", "duration_min": 330, + "status": "on_time", "gate": "B14", "aircraft": "Boeing 787"}, + {"flight_number": "AA456", "airline": "AA", "from": "JFK", "to": "LAX", + "depart_local": "10:00", "arrive_local": "13:30", "duration_min": 390, + "status": "on_time", "gate": "T5-22", "aircraft": "Boeing 777"}, + {"flight_number": "DL789", "airline": "DL", "from": "ATL", "to": "ORD", + "depart_local": "07:15", "arrive_local": "08:45", "duration_min": 150, + "status": "delayed", "gate": "A12", "aircraft": "Airbus A320"}, + {"flight_number": "B6101", "airline": "B6", "from": "BOS", "to": "MIA", + "depart_local": "06:30", "arrive_local": "10:15", "duration_min": 225, + "status": "on_time", "gate": "C8", "aircraft": "Airbus A321"}, + {"flight_number": "UA204", "airline": "UA", "from": "SFO", "to": "SEA", + "depart_local": "09:00", "arrive_local": "11:00", "duration_min": 120, + "status": "on_time", "gate": "F11", "aircraft": "Boeing 737"}, +] + + +class _AsyncFn: + """Tiny shim so we can call find_routes.ainvoke({...}) like the umbrella's + LangChain @tool decorator does.""" + def __init__(self, fn): + self._fn = fn + + async def ainvoke(self, args: dict[str, Any]) -> list[dict[str, Any]]: + return self._fn(**args) + + +def _find_routes_impl(from_code: str, to_code: str, date_offset_days: int = 0) -> list[dict[str, Any]]: + return [f for f in _FLIGHTS if f["from"] == from_code and f["to"] == to_code] + + +find_routes = _AsyncFn(_find_routes_impl) + +_logger = logging.getLogger(__name__) A2UI_PREFIX = "---a2ui_JSON---" -# v0.9 envelope format: each message is {"": {}} -CONTACT_FORM_JSONL = A2UI_PREFIX + "\n" + "\n".join([ - json.dumps({"createSurface": { - "surfaceId": "contact", "catalogId": "basic", "sendDataModel": True, - }}), - json.dumps({"updateDataModel": { - "surfaceId": "contact", - "value": {"name": "", "email": "", "department": "Engineering", "consent": False}, - }}), - json.dumps({"updateComponents": { - "surfaceId": "contact", - "components": [ - {"id": "root", "component": "Column", "children": ["card"]}, - {"id": "card", "component": "Card", "title": "Contact Us", "children": [ - "name_field", "email_field", "dept_picker", "consent_check", "divider", "submit_btn", - ]}, - {"id": "name_field", "component": "TextField", - "label": "Name", "value": {"path": "/name"}, "placeholder": "Your full name", - "checks": [ - {"condition": {"call": "required", "args": {"value": {"path": "/name"}}}, - "message": "Name is required"}, - ]}, - {"id": "email_field", "component": "TextField", - "label": "Email", "value": {"path": "/email"}, "placeholder": "you@company.com", - "checks": [ - {"condition": {"call": "required", "args": {"value": {"path": "/email"}}}, - "message": "Email is required"}, - {"condition": {"call": "email", "args": {"value": {"path": "/email"}}}, - "message": "Must be a valid email address"}, - ]}, - {"id": "dept_picker", "component": "ChoicePicker", - "label": "Department", - "options": ["Engineering", "Sales", "Support", "Marketing"], - "selected": {"path": "/department"}}, - {"id": "consent_check", "component": "CheckBox", - "label": "I agree to be contacted", "checked": {"path": "/consent"}}, - {"id": "divider", "component": "Divider"}, - {"id": "submit_btn", "component": "Button", - "label": "Submit", - "checks": [ - {"condition": {"call": "and", "args": {"values": [ - {"call": "required", "args": {"value": {"path": "/name"}}}, - {"call": "email", "args": {"value": {"path": "/email"}}}, - {"path": "/consent"}, - ]}}, - "message": "Complete all required fields and agree to be contacted"}, - ], - "action": {"event": {"name": "formSubmit", "context": {"formId": "contact"}}}}, - ], - }}), -]) + "\n" # Trailing newline required — parser processes at \n boundaries - - -def build_a2ui_graph(): +# 10 IATA airports from aviation_data.py +AIRPORT_CODES = ["LAX", "JFK", "SFO", "ORD", "BOS", "ATL", "DFW", "SEA", "MIA", "DEN"] +FARE_CLASSES = ["Economy", "Premium", "Business", "First"] + + +# ── Pydantic schemas ──────────────────────────────────────────────────────── + +class A2uiComponent(BaseModel): + """Single A2UI updateComponents entry. + + Literal[...] on `component` is the gate that keeps the LLM from + inventing component types not in the catalog. Pydantic raises + ValidationError if it does, triggering retry. """ - Single-node graph: - - On first message: emits hardcoded A2UI JSONL for the contact form - - On a2ui_event messages: responds with a confirmation message + id: str + component: Literal[ + "Column", "Row", "Card", "TextField", "ChoicePicker", + "NumberField", "DatePicker", "CheckBox", "Button", "Divider", + ] + label: str | None = None + title: str | None = None + placeholder: str | None = None + options: list[str] | None = None + value: dict[str, Any] | None = None + selected: dict[str, Any] | None = None + checked: dict[str, Any] | None = None + children: list[str] | None = None + checks: list[dict[str, Any]] | None = None + action: dict[str, Any] | None = None + + +class _SurfaceSpec(BaseModel): + """Common shape — both booking and results surfaces produce the same + triple (surface_id, data_model, components).""" + surface_id: str = Field(description="Surface id. Use 'booking' for the form, 'results' for flights.") + data_model: dict[str, Any] = Field(description="Initial form/state values, e.g. prefills.") + components: list[A2uiComponent] + + +class BookingFormSpec(_SurfaceSpec): + pass + + +class FlightResultsSpec(_SurfaceSpec): + pass + + +# ── Envelope wrapping ─────────────────────────────────────────────────────── + +def _wrap_envelopes(spec: _SurfaceSpec) -> str: + """Wrap a validated SurfaceSpec into A2UI JSONL with the three required envelopes.""" + lines = [ + json.dumps({"createSurface": { + "surfaceId": spec.surface_id, + "catalogId": "basic", + "sendDataModel": True, + }}), + json.dumps({"updateDataModel": { + "surfaceId": spec.surface_id, + "value": spec.data_model, + }}), + json.dumps({"updateComponents": { + "surfaceId": spec.surface_id, + "components": [c.model_dump(exclude_none=True) for c in spec.components], + }}), + ] + return A2UI_PREFIX + "\n" + "\n".join(lines) + "\n" + + +# ── LLM + retry ───────────────────────────────────────────────────────────── + +# gpt-5 with low reasoning effort: PR #372 established gpt-5 follows +# directive precisely; "low" gives slightly more headroom than "minimal" +# for schema compliance. +_llm: ChatOpenAI | None = None + + +def _get_llm() -> ChatOpenAI: + """Lazy-initialize the LLM so imports succeed without OPENAI_API_KEY set.""" + global _llm + if _llm is None: + _llm = ChatOpenAI( + model="gpt-5", + streaming=True, + reasoning_effort="low", + ) + return _llm + + +async def _emit_with_retry( + spec_cls: type[_SurfaceSpec], + base_messages: list[Any], + max_attempts: int = 3, +) -> _SurfaceSpec: + """Call the LLM with structured output, retrying on validation failure. + + Each retry re-injects the error message so the model has a chance + to correct its output. After max_attempts, raises RuntimeError. """ + llm = _get_llm().with_structured_output(spec_cls) + messages = list(base_messages) + last_err: Exception | None = None + for attempt in range(max_attempts): + try: + return await llm.ainvoke(messages) + except ValidationError as err: + last_err = err + _logger.warning( + "A2UI structured-output validation failed (attempt %d/%d): %s", + attempt + 1, max_attempts, err, + ) + messages = list(base_messages) + [ + AIMessage(content=( + f"Previous attempt failed schema validation: {err}. " + "Try again, strictly matching the schema. " + "Do not invent component types outside the Literal list." + )), + ] + raise RuntimeError( + f"LLM failed structured output after {max_attempts} attempts: {last_err}" + ) + + +# ── build_form node ───────────────────────────────────────────────────────── + +_BUILD_FORM_SYSTEM = f"""You are an aviation booking-form designer. Emit an A2UI booking form using the structured output schema. + +Required components (in this order, inside a single Card titled "Book a flight" inside a Column root): +1. Origin airport ChoicePicker — options = {AIRPORT_CODES}, selected = {{"path": "/origin"}} +2. Destination airport ChoicePicker — same options, selected = {{"path": "/dest"}} +3. Departure date TextField — value = {{"path": "/date"}}, placeholder "YYYY-MM-DD" +4. Passengers NumberField — value = {{"path": "/passengers"}} +5. Fare class ChoicePicker — options = {FARE_CLASSES}, selected = {{"path": "/fare_class"}} +6. Submit Button — label "Search flights", action = {{"event": {{"name": "bookingSubmit", "context": {{"formId": "booking"}}}}}} + +Submit button MUST be gated by `checks` that require: +- /origin set (call: "required") +- /dest set (call: "required") +- /date set (call: "required") + +Default data_model: {{"origin": "", "dest": "", "date": "", "passengers": 1, "fare_class": "Economy"}} +surface_id MUST be "booking". + +Use unique `id` values for every component (e.g. "root", "card", "origin_field", etc.).""" + + +_SENTINEL_BOOKING_FORM = BookingFormSpec( + surface_id="booking", + data_model={"origin": "", "dest": "", "date": "", "passengers": 1, "fare_class": "Economy"}, + components=[ + A2uiComponent(id="root", component="Column", children=["card"]), + A2uiComponent(id="card", component="Card", title="Book a flight (fallback)", + children=["origin", "dest", "date", "passengers", "fare", "submit"]), + A2uiComponent(id="origin", component="ChoicePicker", label="Origin", + options=AIRPORT_CODES, selected={"path": "/origin"}), + A2uiComponent(id="dest", component="ChoicePicker", label="Destination", + options=AIRPORT_CODES, selected={"path": "/dest"}), + A2uiComponent(id="date", component="TextField", label="Date", + value={"path": "/date"}, placeholder="YYYY-MM-DD"), + A2uiComponent(id="passengers", component="NumberField", label="Passengers", + value={"path": "/passengers"}), + A2uiComponent(id="fare", component="ChoicePicker", label="Fare class", + options=FARE_CLASSES, selected={"path": "/fare_class"}), + A2uiComponent(id="submit", component="Button", label="Search flights", + action={"event": {"name": "bookingSubmit", "context": {"formId": "booking"}}}), + ], +) + + +async def build_form(state: MessagesState) -> dict: + """First-turn node: LLM authors the booking form.""" + base_messages = [SystemMessage(content=_BUILD_FORM_SYSTEM)] + state["messages"] + try: + spec = await _emit_with_retry(BookingFormSpec, base_messages) + except RuntimeError as err: + _logger.error("Falling back to sentinel booking form: %s", err) + spec = _SENTINEL_BOOKING_FORM + return {"messages": [AIMessage(content=_wrap_envelopes(spec))]} + - async def create_form(state: MessagesState) -> dict: - last = state["messages"][-1] +# ── search_flights node ───────────────────────────────────────────────────── - # Check if this is a form submission event from the A2UI surface +_SEARCH_FLIGHTS_SYSTEM = """You just received a booking submission. The find_routes() tool returned the following flights: + +{flights_json} + +Form data (for context): {form_json} + +Emit an A2UI results surface using the FlightResultsSpec schema. + +- surface_id MUST be "results" +- data_model can be {{}} (no user input needed on this surface) +- Root is a Column with children = list of flight Card ids (or a single Card "no_flights" if the list is empty) +- For each flight: a Card with title " flight ", containing TextField/Divider children showing route, depart/arrive times, duration, aircraft, gate, and a "Select" Button whose action emits {{"event": {{"name": "flightSelect", "context": {{"flightId": ""}}}}}} +- For the empty case: a single Card with id "no_flights" titled "No flights found", containing a "Modify search" Button with action {{"event": {{"name": "modifySearch", "context": {{"formId": "booking"}}}}}} + +Use unique `id` values for every component.""" + + +_SENTINEL_RESULTS = FlightResultsSpec( + surface_id="results", + data_model={}, + components=[ + A2uiComponent(id="root", component="Column", children=["msg"]), + A2uiComponent(id="msg", component="Card", title="Results unavailable", + children=["modify"]), + A2uiComponent(id="modify", component="Button", label="Modify search", + action={"event": {"name": "modifySearch", "context": {"formId": "booking"}}}), + ], +) + + +def _parse_submit_payload(content: str) -> dict[str, Any] | None: + """Extract the form-data dict from an a2ui_event message content.""" + try: + payload = json.loads(content) + except (json.JSONDecodeError, TypeError): + return None + if not isinstance(payload, dict) or payload.get("type") != "a2ui_event": + return None + # Accept either {"data": {...}} or {"value": {...}} or context-level fields + data = payload.get("data") or payload.get("value") or {} + if not isinstance(data, dict): + return None + return data + + +async def search_flights(state: MessagesState) -> dict: + """Post-submit node: call find_routes, emit results A2UI surface.""" + last = state["messages"][-1] + form_data = _parse_submit_payload(getattr(last, "content", "")) or {} + origin = (form_data.get("origin") or "").upper() + dest = (form_data.get("dest") or "").upper() + flights: list[dict[str, Any]] = [] + if origin and dest and origin != dest: try: - payload = json.loads(last.content) - if isinstance(payload, dict) and payload.get("type") == "a2ui_event": - name = payload.get("context", {}).get("formId", "unknown") - return {"messages": [AIMessage( - content=f"Thanks for submitting the **{name}** form! We'll be in touch soon.", - )]} - except (json.JSONDecodeError, AttributeError): - pass + flights = await find_routes.ainvoke({"from_code": origin, "to_code": dest}) + except Exception as err: # noqa: BLE001 — demo robustness + _logger.warning("find_routes failed for %s→%s: %s", origin, dest, err) + + base_messages = [ + SystemMessage(content=_SEARCH_FLIGHTS_SYSTEM.format( + flights_json=json.dumps(flights, indent=2), + form_json=json.dumps(form_data, indent=2), + )), + HumanMessage(content=f"Emit the results surface for {origin}→{dest}."), + ] + try: + spec = await _emit_with_retry(FlightResultsSpec, base_messages) + except RuntimeError as err: + _logger.error("Falling back to sentinel results surface: %s", err) + spec = _SENTINEL_RESULTS + return {"messages": [AIMessage(content=_wrap_envelopes(spec))]} + + +# ── Routing + compile ─────────────────────────────────────────────────────── + +def _is_submit_event(content: str) -> bool: + """True iff the content is an a2ui_event whose formId is 'booking'.""" + try: + payload = json.loads(content) + except (json.JSONDecodeError, TypeError): + return False + return ( + isinstance(payload, dict) + and payload.get("type") == "a2ui_event" + and isinstance(payload.get("context"), dict) + and payload["context"].get("formId") == "booking" + ) - # Any other message — emit the contact form - return {"messages": [AIMessage(content=CONTACT_FORM_JSONL)]} - graph = StateGraph(MessagesState) - graph.add_node("create_form", create_form) - graph.set_entry_point("create_form") - graph.add_edge("create_form", END) +def route(state: MessagesState) -> Command[Literal["build_form", "search_flights"]]: + """Inspect the last message — submit event → search_flights, else build_form.""" + last_content = getattr(state["messages"][-1], "content", "") if state["messages"] else "" + if _is_submit_event(last_content): + return Command(goto="search_flights") + return Command(goto="build_form") - return graph.compile() +_builder = StateGraph(MessagesState) +_builder.add_node("route", route) +_builder.add_node("build_form", build_form) +_builder.add_node("search_flights", search_flights) +_builder.set_entry_point("route") +_builder.add_edge("build_form", END) +_builder.add_edge("search_flights", END) -graph = build_a2ui_graph() +graph = _builder.compile() From 283b6d9e6f4950fd6de1010cbf323f7977f46b47 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 16 May 2026 14:55:55 -0700 Subject: [PATCH 09/12] fix(c-a2ui): use function_calling structured-output (open dicts unsupported by strict mode) --- cockpit/chat/a2ui/python/src/graph.py | 9 ++++++++- cockpit/langgraph/streaming/python/src/a2ui_graph.py | 9 ++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/cockpit/chat/a2ui/python/src/graph.py b/cockpit/chat/a2ui/python/src/graph.py index 05d635e97..69703d95e 100644 --- a/cockpit/chat/a2ui/python/src/graph.py +++ b/cockpit/chat/a2ui/python/src/graph.py @@ -165,7 +165,14 @@ async def _emit_with_retry( Each retry re-injects the error message so the model has a chance to correct its output. After max_attempts, raises RuntimeError. """ - llm = _get_llm().with_structured_output(spec_cls) + # method="function_calling" is required because our schema uses + # `dict[str, Any]` fields (value/selected/checked/checks/action) for + # A2UI binding payloads. OpenAI's default strict structured-output mode + # demands additionalProperties=false on every nested object and rejects + # open dicts. Function-calling mode is more flexible and the model + # still adheres to the rest of the schema (especially the Literal[...] + # on component type, which is the actual safety gate we need). + llm = _get_llm().with_structured_output(spec_cls, method="function_calling") messages = list(base_messages) last_err: Exception | None = None for attempt in range(max_attempts): diff --git a/cockpit/langgraph/streaming/python/src/a2ui_graph.py b/cockpit/langgraph/streaming/python/src/a2ui_graph.py index 30b952ae0..b7fa81ff7 100644 --- a/cockpit/langgraph/streaming/python/src/a2ui_graph.py +++ b/cockpit/langgraph/streaming/python/src/a2ui_graph.py @@ -131,7 +131,14 @@ async def _emit_with_retry( Each retry re-injects the error message so the model has a chance to correct its output. After max_attempts, raises RuntimeError. """ - llm = _get_llm().with_structured_output(spec_cls) + # method="function_calling" is required because our schema uses + # `dict[str, Any]` fields (value/selected/checked/checks/action) for + # A2UI binding payloads. OpenAI's default strict structured-output mode + # demands additionalProperties=false on every nested object and rejects + # open dicts. Function-calling mode is more flexible and the model + # still adheres to the rest of the schema (especially the Literal[...] + # on component type, which is the actual safety gate we need). + llm = _get_llm().with_structured_output(spec_cls, method="function_calling") messages = list(base_messages) last_err: Exception | None = None for attempt in range(max_attempts): From a586a774ee88111f5dcf7a36e7d9c3c0bfc58f19 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 16 May 2026 15:26:44 -0700 Subject: [PATCH 10/12] fix(c-a2ui): use A2UI v0.9 nested ComponentDef format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat-lib's unwrapComponentDef() expects {id, component: {: {}}} (single-key inner dict) — NOT my prior flat {id, component: "Name", ...props}. Unknown component types silently fall through to a stub Text and render nothing visible. Also corrected: - ChoicePicker → MultipleChoice (catalog name) - NumberField → TextField with textFieldType="number" - DatePicker → TextField with textFieldType="date" - Card takes single `child` (not children list) — wrap multi-field forms in a Card→Column→[fields...] - Button needs `child` pointing at a Text component (label) + action - children format is {explicitList: [...]} - MultipleChoice uses `selections` (plural) + options=[{label,value}] Updated: - A2uiComponent schema → component: dict[str, dict[str, Any]] with field_validator enforcing single key from ALLOWED_COMPONENTS frozenset - Build_form / search_flights system prompts: complete v0.9 component cheatsheet + exact id-tree spec + per-component shape examples - Both sentinel forms rewritten in nested format Mirrored to standalone (cockpit/chat/a2ui/python/src/graph.py). Verified end-to-end: build_form produces 11 components in nested format; search_flights for LAX→JFK produces 8 components including UA123 flight Card + Select Button. Co-Authored-By: Claude Opus 4.7 --- cockpit/chat/a2ui/python/src/graph.py | 215 +++++++++++++----- .../streaming/python/src/a2ui_graph.py | 212 ++++++++++++----- 2 files changed, 302 insertions(+), 125 deletions(-) diff --git a/cockpit/chat/a2ui/python/src/graph.py b/cockpit/chat/a2ui/python/src/graph.py index 69703d95e..460c63d6c 100644 --- a/cockpit/chat/a2ui/python/src/graph.py +++ b/cockpit/chat/a2ui/python/src/graph.py @@ -24,7 +24,8 @@ from langchain_openai import ChatOpenAI from langgraph.graph import StateGraph, MessagesState, END from langgraph.types import Command -from pydantic import BaseModel, Field, ValidationError +from pydantic import BaseModel, Field, ValidationError, field_validator + # Inlined flight fixtures — standalone has no aviation_data module. _FLIGHTS = [ @@ -70,31 +71,60 @@ def _find_routes_impl(from_code: str, to_code: str, date_offset_days: int = 0) - AIRPORT_CODES = ["LAX", "JFK", "SFO", "ORD", "BOS", "ATL", "DFW", "SEA", "MIA", "DEN"] FARE_CLASSES = ["Economy", "Premium", "Business", "First"] +# Catalog component names — must match libs/chat/src/lib/a2ui/catalog/index.ts. +# The chat-lib's unwrapComponentDef() looks for ONE key from this set inside the +# `component` field. Unknown / multiple keys fall through to a stub Text and +# render nothing visible — silent failure mode, hence the field_validator below. +ALLOWED_COMPONENTS = frozenset({ + "AudioPlayer", "Button", "Card", "CheckBox", "Column", "DateTimeInput", + "Divider", "Icon", "Image", "List", "Modal", "MultipleChoice", "Row", + "Slider", "Tabs", "Text", "TextField", "Video", +}) + # ── Pydantic schemas ──────────────────────────────────────────────────────── class A2uiComponent(BaseModel): - """Single A2UI updateComponents entry. - - Literal[...] on `component` is the gate that keeps the LLM from - inventing component types not in the catalog. Pydantic raises - ValidationError if it does, triggering retry. + """Single A2UI v0.9 updateComponents entry. + + Format (from libs/chat/src/lib/a2ui/surface-to-spec.ts): + {id: "name_field", + component: {TextField: {label: "Name", text: {path: "/name"}}}} + + The `component` field MUST be a single-key dict whose key is one of + ALLOWED_COMPONENTS. The inner dict is the per-component props (see + libs/a2ui/src/lib/types.ts for the per-component shapes). + + Key per-component notes the LLM must respect: + Card({child: ""}) — single child only + Button({child: "", action: {...}}) — child is a Text id (label) + Column/Row/List({children: {explicitList:[id,...]}}) + TextField({label, text: {path:"/p"}, textFieldType: "shortText"|"number"|"date"|...}) + MultipleChoice({label, options:[{label,value}], selections:{path}, maxAllowedSelections:1}) + Text({text: "literal or {path:'/p'}", usageHint?: "h1"|"h2"|"body"|...}) + Divider({}) """ id: str - component: Literal[ - "Column", "Row", "Card", "TextField", "ChoicePicker", - "NumberField", "DatePicker", "CheckBox", "Button", "Divider", - ] - label: str | None = None - title: str | None = None - placeholder: str | None = None - options: list[str] | None = None - value: dict[str, Any] | None = None - selected: dict[str, Any] | None = None - checked: dict[str, Any] | None = None - children: list[str] | None = None - checks: list[dict[str, Any]] | None = None - action: dict[str, Any] | None = None + component: dict[str, dict[str, Any]] = Field( + description=( + "Single-key map {ComponentName: {props}}. ComponentName must be " + "one of: " + ", ".join(sorted(ALLOWED_COMPONENTS)) + ), + ) + + @field_validator("component") + @classmethod + def _single_known_key(cls, v: dict[str, Any]) -> dict[str, Any]: + if not isinstance(v, dict) or len(v) != 1: + raise ValueError( + f"component must be a single-key dict, got keys: {list(v) if isinstance(v, dict) else type(v)}" + ) + key = next(iter(v)) + if key not in ALLOWED_COMPONENTS: + raise ValueError( + f"component '{key}' not in catalog. Allowed: {sorted(ALLOWED_COMPONENTS)}" + ) + return v class _SurfaceSpec(BaseModel): @@ -198,46 +228,73 @@ async def _emit_with_retry( # ── build_form node ───────────────────────────────────────────────────────── -_BUILD_FORM_SYSTEM = f"""You are an aviation booking-form designer. Emit an A2UI booking form using the structured output schema. +_AIRPORT_OPTIONS = [{"label": c, "value": c} for c in AIRPORT_CODES] +_FARE_OPTIONS = [{"label": c, "value": c} for c in FARE_CLASSES] + +_BUILD_FORM_SYSTEM = f"""You are an aviation booking-form designer. Emit an A2UI v0.9 booking form using the structured output schema. + +A2UI FORMAT (CRITICAL): each component is `{{"id": "...", "component": {{"": {{}}}}}}`. The component name is the SINGLE KEY of the inner dict. ComponentName must be one of: + Column, Row, Card, Text, TextField, MultipleChoice, DateTimeInput, CheckBox, Button, Divider, List, Image, Icon, Modal, Slider, Tabs + +Per-component shapes: + Column / Row / List: {{"children": {{"explicitList": ["id1", "id2"]}}}} + Card: {{"child": ""}} ← single child only + Button: {{"child": "", "primary": true, "action": {{"name": "", "context": [{{"key":"formId","value":"booking"}}]}}}} + Text: {{"text": "literal string", "usageHint": "h2"}} (h1/h2/h3/h4/h5/caption/body) + TextField: {{"label": "Field", "text": {{"path": "/p"}}, "textFieldType": "shortText"}} (shortText/longText/number/date/obscured) + MultipleChoice: {{"label": "Origin", "options": [{{"label":"LAX","value":"LAX"}}], "selections": {{"path":"/origin"}}, "maxAllowedSelections": 1}} + CheckBox: {{"label": "...", "checked": {{"path":"/p"}}}} + Divider: {{}} -Required components (in this order, inside a single Card titled "Book a flight" inside a Column root): -1. Origin airport ChoicePicker — options = {AIRPORT_CODES}, selected = {{"path": "/origin"}} -2. Destination airport ChoicePicker — same options, selected = {{"path": "/dest"}} -3. Departure date TextField — value = {{"path": "/date"}}, placeholder "YYYY-MM-DD" -4. Passengers NumberField — value = {{"path": "/passengers"}} -5. Fare class ChoicePicker — options = {FARE_CLASSES}, selected = {{"path": "/fare_class"}} -6. Submit Button — label "Search flights", action = {{"event": {{"name": "bookingSubmit", "context": {{"formId": "booking"}}}}}} +Required form composition for THIS task: + surface_id MUST be "booking" + data_model MUST be {{"origin": "", "dest": "", "date": "", "passengers": 1, "fare_class": "Economy"}} -Submit button MUST be gated by `checks` that require: -- /origin set (call: "required") -- /dest set (call: "required") -- /date set (call: "required") + Build this component tree: + root (Column, children=[card]) + card (Card, child=card_col) + card_col (Column, children=[title, origin, dest, date, passengers, fare, submit]) + title (Text, text="Book a flight", usageHint="h2") + origin (MultipleChoice, label="Origin", options={_AIRPORT_OPTIONS}, selections={{"path":"/origin"}}, maxAllowedSelections=1) + dest (MultipleChoice, label="Destination", options={_AIRPORT_OPTIONS}, selections={{"path":"/dest"}}, maxAllowedSelections=1) + date (TextField, label="Departure date (YYYY-MM-DD)", text={{"path":"/date"}}, textFieldType="date") + passengers (TextField, label="Passengers", text={{"path":"/passengers"}}, textFieldType="number") + fare (MultipleChoice, label="Fare class", options={_FARE_OPTIONS}, selections={{"path":"/fare_class"}}, maxAllowedSelections=1) + submit (Button, child=submit_label, primary=true, action={{"name":"bookingSubmit","context":[{{"key":"formId","value":"booking"}}]}}) + submit_label (Text, text="Search flights") -Default data_model: {{"origin": "", "dest": "", "date": "", "passengers": 1, "fare_class": "Economy"}} -surface_id MUST be "booking". +Use these exact ids.""" -Use unique `id` values for every component (e.g. "root", "card", "origin_field", etc.).""" + +def _comp(id_: str, name: str, props: dict[str, Any]) -> A2uiComponent: + """Tiny helper so the sentinels read naturally.""" + return A2uiComponent(id=id_, component={name: props}) _SENTINEL_BOOKING_FORM = BookingFormSpec( surface_id="booking", data_model={"origin": "", "dest": "", "date": "", "passengers": 1, "fare_class": "Economy"}, components=[ - A2uiComponent(id="root", component="Column", children=["card"]), - A2uiComponent(id="card", component="Card", title="Book a flight (fallback)", - children=["origin", "dest", "date", "passengers", "fare", "submit"]), - A2uiComponent(id="origin", component="ChoicePicker", label="Origin", - options=AIRPORT_CODES, selected={"path": "/origin"}), - A2uiComponent(id="dest", component="ChoicePicker", label="Destination", - options=AIRPORT_CODES, selected={"path": "/dest"}), - A2uiComponent(id="date", component="TextField", label="Date", - value={"path": "/date"}, placeholder="YYYY-MM-DD"), - A2uiComponent(id="passengers", component="NumberField", label="Passengers", - value={"path": "/passengers"}), - A2uiComponent(id="fare", component="ChoicePicker", label="Fare class", - options=FARE_CLASSES, selected={"path": "/fare_class"}), - A2uiComponent(id="submit", component="Button", label="Search flights", - action={"event": {"name": "bookingSubmit", "context": {"formId": "booking"}}}), + _comp("root", "Column", {"children": {"explicitList": ["card"]}}), + _comp("card", "Card", {"child": "card_col"}), + _comp("card_col", "Column", {"children": {"explicitList": [ + "title", "origin", "dest", "date", "passengers", "fare", "submit", + ]}}), + _comp("title", "Text", {"text": "Book a flight (fallback)", "usageHint": "h2"}), + _comp("origin", "MultipleChoice", {"label": "Origin", "options": _AIRPORT_OPTIONS, + "selections": {"path": "/origin"}, "maxAllowedSelections": 1}), + _comp("dest", "MultipleChoice", {"label": "Destination", "options": _AIRPORT_OPTIONS, + "selections": {"path": "/dest"}, "maxAllowedSelections": 1}), + _comp("date", "TextField", {"label": "Departure date (YYYY-MM-DD)", + "text": {"path": "/date"}, "textFieldType": "date"}), + _comp("passengers", "TextField", {"label": "Passengers", + "text": {"path": "/passengers"}, "textFieldType": "number"}), + _comp("fare", "MultipleChoice", {"label": "Fare class", "options": _FARE_OPTIONS, + "selections": {"path": "/fare_class"}, "maxAllowedSelections": 1}), + _comp("submit", "Button", {"child": "submit_label", "primary": True, + "action": {"name": "bookingSubmit", + "context": [{"key": "formId", "value": "booking"}]}}), + _comp("submit_label", "Text", {"text": "Search flights"}), ], ) @@ -261,26 +318,58 @@ async def build_form(state: MessagesState) -> dict: Form data (for context): {form_json} -Emit an A2UI results surface using the FlightResultsSpec schema. - -- surface_id MUST be "results" -- data_model can be {{}} (no user input needed on this surface) -- Root is a Column with children = list of flight Card ids (or a single Card "no_flights" if the list is empty) -- For each flight: a Card with title " flight ", containing TextField/Divider children showing route, depart/arrive times, duration, aircraft, gate, and a "Select" Button whose action emits {{"event": {{"name": "flightSelect", "context": {{"flightId": ""}}}}}} -- For the empty case: a single Card with id "no_flights" titled "No flights found", containing a "Modify search" Button with action {{"event": {{"name": "modifySearch", "context": {{"formId": "booking"}}}}}} +Emit an A2UI v0.9 results surface using the FlightResultsSpec schema. + +A2UI format (CRITICAL): every component is `{{"id": "...", "component": {{"": {{}}}}}}`. The component name is the SINGLE KEY of the inner dict. + +Allowed component names: Column, Row, Card, Text, TextField, Button, Divider, List. + +Per-component shapes you'll need: + Column / List: {{"children": {{"explicitList": ["id1", "id2"]}}}} + Card: {{"child": ""}} + Text: {{"text": "literal", "usageHint": "h2"}} (or h1/h3/body/caption) + Button: {{"child": "", "primary": true, "action": {{"name": "", "context": [{{"key":"flightId","value":""}}]}}}} + Divider: {{}} + +Surface constraints: + surface_id MUST be "results" + data_model can be {{}} + Root = a Column whose explicitList lists every flight Card id (or just ["no_flights"] when empty) + +Build pattern (one per flight): + card_ (Card, child=col_) + col_ (Column, children explicitList = [title_, route_, time_, btn_]) + title_ (Text, text=" flight ", usageHint="h3") + route_ (Text, text=" min • ", usageHint="body") + time_ (Text, text="Depart • Arrive • Gate ", usageHint="caption") + btn_ (Button, child=btn_label_, primary=true, + action={{"name":"flightSelect","context":[{{"key":"flightId","value":""}}]}}) + btn_label_ (Text, text="Select") + +Empty case: components = [ + {{"id":"root", "component":{{"Column":{{"children":{{"explicitList":["no_flights"]}}}}}}}}, + {{"id":"no_flights","component":{{"Card":{{"child":"empty_col"}}}}}}, + {{"id":"empty_col","component":{{"Column":{{"children":{{"explicitList":["empty_msg","modify_btn"]}}}}}}}}, + {{"id":"empty_msg","component":{{"Text":{{"text":"No flights found","usageHint":"h3"}}}}}}, + {{"id":"modify_btn","component":{{"Button":{{"child":"modify_label","action":{{"name":"modifySearch","context":[{{"key":"formId","value":"booking"}}]}}}}}}}}, + {{"id":"modify_label","component":{{"Text":{{"text":"Modify search"}}}}}} +] -Use unique `id` values for every component.""" +Use unique ids for every component.""" _SENTINEL_RESULTS = FlightResultsSpec( surface_id="results", data_model={}, components=[ - A2uiComponent(id="root", component="Column", children=["msg"]), - A2uiComponent(id="msg", component="Card", title="Results unavailable", - children=["modify"]), - A2uiComponent(id="modify", component="Button", label="Modify search", - action={"event": {"name": "modifySearch", "context": {"formId": "booking"}}}), + _comp("root", "Column", {"children": {"explicitList": ["msg"]}}), + _comp("msg", "Card", {"child": "msg_col"}), + _comp("msg_col", "Column", {"children": {"explicitList": ["msg_text", "modify"]}}), + _comp("msg_text", "Text", {"text": "Results unavailable", "usageHint": "h3"}), + _comp("modify", "Button", {"child": "modify_label", + "action": {"name": "modifySearch", + "context": [{"key": "formId", "value": "booking"}]}}), + _comp("modify_label", "Text", {"text": "Modify search"}), ], ) diff --git a/cockpit/langgraph/streaming/python/src/a2ui_graph.py b/cockpit/langgraph/streaming/python/src/a2ui_graph.py index b7fa81ff7..07e53c971 100644 --- a/cockpit/langgraph/streaming/python/src/a2ui_graph.py +++ b/cockpit/langgraph/streaming/python/src/a2ui_graph.py @@ -24,7 +24,7 @@ from langchain_openai import ChatOpenAI from langgraph.graph import StateGraph, MessagesState, END from langgraph.types import Command -from pydantic import BaseModel, Field, ValidationError +from pydantic import BaseModel, Field, ValidationError, field_validator from src.aviation_tools import find_routes # noqa: E402 @@ -36,31 +36,60 @@ AIRPORT_CODES = ["LAX", "JFK", "SFO", "ORD", "BOS", "ATL", "DFW", "SEA", "MIA", "DEN"] FARE_CLASSES = ["Economy", "Premium", "Business", "First"] +# Catalog component names — must match libs/chat/src/lib/a2ui/catalog/index.ts. +# The chat-lib's unwrapComponentDef() looks for ONE key from this set inside the +# `component` field. Unknown / multiple keys fall through to a stub Text and +# render nothing visible — silent failure mode, hence the field_validator below. +ALLOWED_COMPONENTS = frozenset({ + "AudioPlayer", "Button", "Card", "CheckBox", "Column", "DateTimeInput", + "Divider", "Icon", "Image", "List", "Modal", "MultipleChoice", "Row", + "Slider", "Tabs", "Text", "TextField", "Video", +}) + # ── Pydantic schemas ──────────────────────────────────────────────────────── class A2uiComponent(BaseModel): - """Single A2UI updateComponents entry. - - Literal[...] on `component` is the gate that keeps the LLM from - inventing component types not in the catalog. Pydantic raises - ValidationError if it does, triggering retry. + """Single A2UI v0.9 updateComponents entry. + + Format (from libs/chat/src/lib/a2ui/surface-to-spec.ts): + {id: "name_field", + component: {TextField: {label: "Name", text: {path: "/name"}}}} + + The `component` field MUST be a single-key dict whose key is one of + ALLOWED_COMPONENTS. The inner dict is the per-component props (see + libs/a2ui/src/lib/types.ts for the per-component shapes). + + Key per-component notes the LLM must respect: + Card({child: ""}) — single child only + Button({child: "", action: {...}}) — child is a Text id (label) + Column/Row/List({children: {explicitList:[id,...]}}) + TextField({label, text: {path:"/p"}, textFieldType: "shortText"|"number"|"date"|...}) + MultipleChoice({label, options:[{label,value}], selections:{path}, maxAllowedSelections:1}) + Text({text: "literal or {path:'/p'}", usageHint?: "h1"|"h2"|"body"|...}) + Divider({}) """ id: str - component: Literal[ - "Column", "Row", "Card", "TextField", "ChoicePicker", - "NumberField", "DatePicker", "CheckBox", "Button", "Divider", - ] - label: str | None = None - title: str | None = None - placeholder: str | None = None - options: list[str] | None = None - value: dict[str, Any] | None = None - selected: dict[str, Any] | None = None - checked: dict[str, Any] | None = None - children: list[str] | None = None - checks: list[dict[str, Any]] | None = None - action: dict[str, Any] | None = None + component: dict[str, dict[str, Any]] = Field( + description=( + "Single-key map {ComponentName: {props}}. ComponentName must be " + "one of: " + ", ".join(sorted(ALLOWED_COMPONENTS)) + ), + ) + + @field_validator("component") + @classmethod + def _single_known_key(cls, v: dict[str, Any]) -> dict[str, Any]: + if not isinstance(v, dict) or len(v) != 1: + raise ValueError( + f"component must be a single-key dict, got keys: {list(v) if isinstance(v, dict) else type(v)}" + ) + key = next(iter(v)) + if key not in ALLOWED_COMPONENTS: + raise ValueError( + f"component '{key}' not in catalog. Allowed: {sorted(ALLOWED_COMPONENTS)}" + ) + return v class _SurfaceSpec(BaseModel): @@ -164,46 +193,73 @@ async def _emit_with_retry( # ── build_form node ───────────────────────────────────────────────────────── -_BUILD_FORM_SYSTEM = f"""You are an aviation booking-form designer. Emit an A2UI booking form using the structured output schema. +_AIRPORT_OPTIONS = [{"label": c, "value": c} for c in AIRPORT_CODES] +_FARE_OPTIONS = [{"label": c, "value": c} for c in FARE_CLASSES] + +_BUILD_FORM_SYSTEM = f"""You are an aviation booking-form designer. Emit an A2UI v0.9 booking form using the structured output schema. -Required components (in this order, inside a single Card titled "Book a flight" inside a Column root): -1. Origin airport ChoicePicker — options = {AIRPORT_CODES}, selected = {{"path": "/origin"}} -2. Destination airport ChoicePicker — same options, selected = {{"path": "/dest"}} -3. Departure date TextField — value = {{"path": "/date"}}, placeholder "YYYY-MM-DD" -4. Passengers NumberField — value = {{"path": "/passengers"}} -5. Fare class ChoicePicker — options = {FARE_CLASSES}, selected = {{"path": "/fare_class"}} -6. Submit Button — label "Search flights", action = {{"event": {{"name": "bookingSubmit", "context": {{"formId": "booking"}}}}}} +A2UI FORMAT (CRITICAL): each component is `{{"id": "...", "component": {{"": {{}}}}}}`. The component name is the SINGLE KEY of the inner dict. ComponentName must be one of: + Column, Row, Card, Text, TextField, MultipleChoice, DateTimeInput, CheckBox, Button, Divider, List, Image, Icon, Modal, Slider, Tabs -Submit button MUST be gated by `checks` that require: -- /origin set (call: "required") -- /dest set (call: "required") -- /date set (call: "required") +Per-component shapes: + Column / Row / List: {{"children": {{"explicitList": ["id1", "id2"]}}}} + Card: {{"child": ""}} ← single child only + Button: {{"child": "", "primary": true, "action": {{"name": "", "context": [{{"key":"formId","value":"booking"}}]}}}} + Text: {{"text": "literal string", "usageHint": "h2"}} (h1/h2/h3/h4/h5/caption/body) + TextField: {{"label": "Field", "text": {{"path": "/p"}}, "textFieldType": "shortText"}} (shortText/longText/number/date/obscured) + MultipleChoice: {{"label": "Origin", "options": [{{"label":"LAX","value":"LAX"}}], "selections": {{"path":"/origin"}}, "maxAllowedSelections": 1}} + CheckBox: {{"label": "...", "checked": {{"path":"/p"}}}} + Divider: {{}} -Default data_model: {{"origin": "", "dest": "", "date": "", "passengers": 1, "fare_class": "Economy"}} -surface_id MUST be "booking". +Required form composition for THIS task: + surface_id MUST be "booking" + data_model MUST be {{"origin": "", "dest": "", "date": "", "passengers": 1, "fare_class": "Economy"}} -Use unique `id` values for every component (e.g. "root", "card", "origin_field", etc.).""" + Build this component tree: + root (Column, children=[card]) + card (Card, child=card_col) + card_col (Column, children=[title, origin, dest, date, passengers, fare, submit]) + title (Text, text="Book a flight", usageHint="h2") + origin (MultipleChoice, label="Origin", options={_AIRPORT_OPTIONS}, selections={{"path":"/origin"}}, maxAllowedSelections=1) + dest (MultipleChoice, label="Destination", options={_AIRPORT_OPTIONS}, selections={{"path":"/dest"}}, maxAllowedSelections=1) + date (TextField, label="Departure date (YYYY-MM-DD)", text={{"path":"/date"}}, textFieldType="date") + passengers (TextField, label="Passengers", text={{"path":"/passengers"}}, textFieldType="number") + fare (MultipleChoice, label="Fare class", options={_FARE_OPTIONS}, selections={{"path":"/fare_class"}}, maxAllowedSelections=1) + submit (Button, child=submit_label, primary=true, action={{"name":"bookingSubmit","context":[{{"key":"formId","value":"booking"}}]}}) + submit_label (Text, text="Search flights") + +Use these exact ids.""" + + +def _comp(id_: str, name: str, props: dict[str, Any]) -> A2uiComponent: + """Tiny helper so the sentinels read naturally.""" + return A2uiComponent(id=id_, component={name: props}) _SENTINEL_BOOKING_FORM = BookingFormSpec( surface_id="booking", data_model={"origin": "", "dest": "", "date": "", "passengers": 1, "fare_class": "Economy"}, components=[ - A2uiComponent(id="root", component="Column", children=["card"]), - A2uiComponent(id="card", component="Card", title="Book a flight (fallback)", - children=["origin", "dest", "date", "passengers", "fare", "submit"]), - A2uiComponent(id="origin", component="ChoicePicker", label="Origin", - options=AIRPORT_CODES, selected={"path": "/origin"}), - A2uiComponent(id="dest", component="ChoicePicker", label="Destination", - options=AIRPORT_CODES, selected={"path": "/dest"}), - A2uiComponent(id="date", component="TextField", label="Date", - value={"path": "/date"}, placeholder="YYYY-MM-DD"), - A2uiComponent(id="passengers", component="NumberField", label="Passengers", - value={"path": "/passengers"}), - A2uiComponent(id="fare", component="ChoicePicker", label="Fare class", - options=FARE_CLASSES, selected={"path": "/fare_class"}), - A2uiComponent(id="submit", component="Button", label="Search flights", - action={"event": {"name": "bookingSubmit", "context": {"formId": "booking"}}}), + _comp("root", "Column", {"children": {"explicitList": ["card"]}}), + _comp("card", "Card", {"child": "card_col"}), + _comp("card_col", "Column", {"children": {"explicitList": [ + "title", "origin", "dest", "date", "passengers", "fare", "submit", + ]}}), + _comp("title", "Text", {"text": "Book a flight (fallback)", "usageHint": "h2"}), + _comp("origin", "MultipleChoice", {"label": "Origin", "options": _AIRPORT_OPTIONS, + "selections": {"path": "/origin"}, "maxAllowedSelections": 1}), + _comp("dest", "MultipleChoice", {"label": "Destination", "options": _AIRPORT_OPTIONS, + "selections": {"path": "/dest"}, "maxAllowedSelections": 1}), + _comp("date", "TextField", {"label": "Departure date (YYYY-MM-DD)", + "text": {"path": "/date"}, "textFieldType": "date"}), + _comp("passengers", "TextField", {"label": "Passengers", + "text": {"path": "/passengers"}, "textFieldType": "number"}), + _comp("fare", "MultipleChoice", {"label": "Fare class", "options": _FARE_OPTIONS, + "selections": {"path": "/fare_class"}, "maxAllowedSelections": 1}), + _comp("submit", "Button", {"child": "submit_label", "primary": True, + "action": {"name": "bookingSubmit", + "context": [{"key": "formId", "value": "booking"}]}}), + _comp("submit_label", "Text", {"text": "Search flights"}), ], ) @@ -227,26 +283,58 @@ async def build_form(state: MessagesState) -> dict: Form data (for context): {form_json} -Emit an A2UI results surface using the FlightResultsSpec schema. +Emit an A2UI v0.9 results surface using the FlightResultsSpec schema. + +A2UI format (CRITICAL): every component is `{{"id": "...", "component": {{"": {{}}}}}}`. The component name is the SINGLE KEY of the inner dict. + +Allowed component names: Column, Row, Card, Text, TextField, Button, Divider, List. + +Per-component shapes you'll need: + Column / List: {{"children": {{"explicitList": ["id1", "id2"]}}}} + Card: {{"child": ""}} + Text: {{"text": "literal", "usageHint": "h2"}} (or h1/h3/body/caption) + Button: {{"child": "", "primary": true, "action": {{"name": "", "context": [{{"key":"flightId","value":""}}]}}}} + Divider: {{}} + +Surface constraints: + surface_id MUST be "results" + data_model can be {{}} + Root = a Column whose explicitList lists every flight Card id (or just ["no_flights"] when empty) + +Build pattern (one per flight): + card_ (Card, child=col_) + col_ (Column, children explicitList = [title_, route_, time_, btn_]) + title_ (Text, text=" flight ", usageHint="h3") + route_ (Text, text=" min • ", usageHint="body") + time_ (Text, text="Depart • Arrive • Gate ", usageHint="caption") + btn_ (Button, child=btn_label_, primary=true, + action={{"name":"flightSelect","context":[{{"key":"flightId","value":""}}]}}) + btn_label_ (Text, text="Select") -- surface_id MUST be "results" -- data_model can be {{}} (no user input needed on this surface) -- Root is a Column with children = list of flight Card ids (or a single Card "no_flights" if the list is empty) -- For each flight: a Card with title " flight ", containing TextField/Divider children showing route, depart/arrive times, duration, aircraft, gate, and a "Select" Button whose action emits {{"event": {{"name": "flightSelect", "context": {{"flightId": ""}}}}}} -- For the empty case: a single Card with id "no_flights" titled "No flights found", containing a "Modify search" Button with action {{"event": {{"name": "modifySearch", "context": {{"formId": "booking"}}}}}} +Empty case: components = [ + {{"id":"root", "component":{{"Column":{{"children":{{"explicitList":["no_flights"]}}}}}}}}, + {{"id":"no_flights","component":{{"Card":{{"child":"empty_col"}}}}}}, + {{"id":"empty_col","component":{{"Column":{{"children":{{"explicitList":["empty_msg","modify_btn"]}}}}}}}}, + {{"id":"empty_msg","component":{{"Text":{{"text":"No flights found","usageHint":"h3"}}}}}}, + {{"id":"modify_btn","component":{{"Button":{{"child":"modify_label","action":{{"name":"modifySearch","context":[{{"key":"formId","value":"booking"}}]}}}}}}}}, + {{"id":"modify_label","component":{{"Text":{{"text":"Modify search"}}}}}} +] -Use unique `id` values for every component.""" +Use unique ids for every component.""" _SENTINEL_RESULTS = FlightResultsSpec( surface_id="results", data_model={}, components=[ - A2uiComponent(id="root", component="Column", children=["msg"]), - A2uiComponent(id="msg", component="Card", title="Results unavailable", - children=["modify"]), - A2uiComponent(id="modify", component="Button", label="Modify search", - action={"event": {"name": "modifySearch", "context": {"formId": "booking"}}}), + _comp("root", "Column", {"children": {"explicitList": ["msg"]}}), + _comp("msg", "Card", {"child": "msg_col"}), + _comp("msg_col", "Column", {"children": {"explicitList": ["msg_text", "modify"]}}), + _comp("msg_text", "Text", {"text": "Results unavailable", "usageHint": "h3"}), + _comp("modify", "Button", {"child": "modify_label", + "action": {"name": "modifySearch", + "context": [{"key": "formId", "value": "booking"}]}}), + _comp("modify_label", "Text", {"text": "Modify search"}), ], ) From 26d7bf59d955ef6143a432ac9ec2050fc0f43213 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 16 May 2026 15:43:08 -0700 Subject: [PATCH 11/12] fix(c-a2ui): use correct A2UI v0.9 envelope keys (surfaceUpdate/dataModelUpdate/beginRendering) --- cockpit/chat/a2ui/python/src/graph.py | 41 +++++++++++++++---- .../streaming/python/src/a2ui_graph.py | 41 +++++++++++++++---- 2 files changed, 64 insertions(+), 18 deletions(-) diff --git a/cockpit/chat/a2ui/python/src/graph.py b/cockpit/chat/a2ui/python/src/graph.py index 460c63d6c..7f01b4543 100644 --- a/cockpit/chat/a2ui/python/src/graph.py +++ b/cockpit/chat/a2ui/python/src/graph.py @@ -145,21 +145,44 @@ class FlightResultsSpec(_SurfaceSpec): # ── Envelope wrapping ─────────────────────────────────────────────────────── -def _wrap_envelopes(spec: _SurfaceSpec) -> str: - """Wrap a validated SurfaceSpec into A2UI JSONL with the three required envelopes.""" +# Chat-lib parser (libs/a2ui/src/lib/parser.ts) accepts exactly four envelope +# keys: surfaceUpdate, dataModelUpdate, beginRendering, deleteSurface. Anything +# else (e.g. createSurface, updateComponents) is silently dropped. Surface is +# created implicitly on first surfaceUpdate; beginRendering is what actually +# triggers mount and must include the root component id. + +def _to_data_model_contents(model: dict[str, Any]) -> list[dict[str, Any]]: + """Convert a flat {key:value} dict to the typed A2uiDataModelEntry list shape.""" + contents: list[dict[str, Any]] = [] + for k, v in model.items(): + if isinstance(v, bool): + contents.append({"key": k, "valueBoolean": v}) + elif isinstance(v, (int, float)): + contents.append({"key": k, "valueNumber": float(v)}) + elif isinstance(v, str): + contents.append({"key": k, "valueString": v}) + # dict/list nested values not supported in this demo + return contents + + +def _wrap_envelopes(spec: _SurfaceSpec, root_id: str = "root") -> str: + """Wrap a validated SurfaceSpec into A2UI v0.9 JSONL. + + Order matters: dataModelUpdate first (so bindings resolve), then + surfaceUpdate (components), then beginRendering (mount + root id). + """ lines = [ - json.dumps({"createSurface": { + json.dumps({"dataModelUpdate": { "surfaceId": spec.surface_id, - "catalogId": "basic", - "sendDataModel": True, + "contents": _to_data_model_contents(spec.data_model), }}), - json.dumps({"updateDataModel": { + json.dumps({"surfaceUpdate": { "surfaceId": spec.surface_id, - "value": spec.data_model, + "components": [c.model_dump(exclude_none=True) for c in spec.components], }}), - json.dumps({"updateComponents": { + json.dumps({"beginRendering": { "surfaceId": spec.surface_id, - "components": [c.model_dump(exclude_none=True) for c in spec.components], + "root": root_id, }}), ] return A2UI_PREFIX + "\n" + "\n".join(lines) + "\n" diff --git a/cockpit/langgraph/streaming/python/src/a2ui_graph.py b/cockpit/langgraph/streaming/python/src/a2ui_graph.py index 07e53c971..674c0c906 100644 --- a/cockpit/langgraph/streaming/python/src/a2ui_graph.py +++ b/cockpit/langgraph/streaming/python/src/a2ui_graph.py @@ -110,21 +110,44 @@ class FlightResultsSpec(_SurfaceSpec): # ── Envelope wrapping ─────────────────────────────────────────────────────── -def _wrap_envelopes(spec: _SurfaceSpec) -> str: - """Wrap a validated SurfaceSpec into A2UI JSONL with the three required envelopes.""" +# Chat-lib parser (libs/a2ui/src/lib/parser.ts) accepts exactly four envelope +# keys: surfaceUpdate, dataModelUpdate, beginRendering, deleteSurface. Anything +# else (e.g. createSurface, updateComponents) is silently dropped. Surface is +# created implicitly on first surfaceUpdate; beginRendering is what actually +# triggers mount and must include the root component id. + +def _to_data_model_contents(model: dict[str, Any]) -> list[dict[str, Any]]: + """Convert a flat {key:value} dict to the typed A2uiDataModelEntry list shape.""" + contents: list[dict[str, Any]] = [] + for k, v in model.items(): + if isinstance(v, bool): + contents.append({"key": k, "valueBoolean": v}) + elif isinstance(v, (int, float)): + contents.append({"key": k, "valueNumber": float(v)}) + elif isinstance(v, str): + contents.append({"key": k, "valueString": v}) + # dict/list nested values not supported in this demo + return contents + + +def _wrap_envelopes(spec: _SurfaceSpec, root_id: str = "root") -> str: + """Wrap a validated SurfaceSpec into A2UI v0.9 JSONL. + + Order matters: dataModelUpdate first (so bindings resolve), then + surfaceUpdate (components), then beginRendering (mount + root id). + """ lines = [ - json.dumps({"createSurface": { + json.dumps({"dataModelUpdate": { "surfaceId": spec.surface_id, - "catalogId": "basic", - "sendDataModel": True, + "contents": _to_data_model_contents(spec.data_model), }}), - json.dumps({"updateDataModel": { + json.dumps({"surfaceUpdate": { "surfaceId": spec.surface_id, - "value": spec.data_model, + "components": [c.model_dump(exclude_none=True) for c in spec.components], }}), - json.dumps({"updateComponents": { + json.dumps({"beginRendering": { "surfaceId": spec.surface_id, - "components": [c.model_dump(exclude_none=True) for c in spec.components], + "root": root_id, }}), ] return A2UI_PREFIX + "\n" + "\n".join(lines) + "\n" From 4761da62854ca73dde8f03a676645ea92d00c27e Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 16 May 2026 15:53:35 -0700 Subject: [PATCH 12/12] fix(c-a2ui): v0.9 ActionMessage submit detection + include form data in submit context --- cockpit/chat/a2ui/python/src/graph.py | 68 +++++++++++++------ .../streaming/python/src/a2ui_graph.py | 60 ++++++++++++---- 2 files changed, 95 insertions(+), 33 deletions(-) diff --git a/cockpit/chat/a2ui/python/src/graph.py b/cockpit/chat/a2ui/python/src/graph.py index 7f01b4543..1fb55adba 100644 --- a/cockpit/chat/a2ui/python/src/graph.py +++ b/cockpit/chat/a2ui/python/src/graph.py @@ -27,7 +27,7 @@ from pydantic import BaseModel, Field, ValidationError, field_validator -# Inlined flight fixtures — standalone has no aviation_data module. +# Inlined flight fixtures - standalone has no aviation_data module. _FLIGHTS = [ {"flight_number": "UA123", "airline": "UA", "from": "LAX", "to": "JFK", "depart_local": "08:00", "arrive_local": "16:30", "duration_min": 330, @@ -48,16 +48,14 @@ class _AsyncFn: - """Tiny shim so we can call find_routes.ainvoke({...}) like the umbrella's - LangChain @tool decorator does.""" def __init__(self, fn): self._fn = fn - async def ainvoke(self, args: dict[str, Any]) -> list[dict[str, Any]]: + async def ainvoke(self, args): return self._fn(**args) -def _find_routes_impl(from_code: str, to_code: str, date_offset_days: int = 0) -> list[dict[str, Any]]: +def _find_routes_impl(from_code, to_code, date_offset_days=0): return [f for f in _FLIGHTS if f["from"] == from_code and f["to"] == to_code] @@ -283,7 +281,14 @@ async def _emit_with_retry( date (TextField, label="Departure date (YYYY-MM-DD)", text={{"path":"/date"}}, textFieldType="date") passengers (TextField, label="Passengers", text={{"path":"/passengers"}}, textFieldType="number") fare (MultipleChoice, label="Fare class", options={_FARE_OPTIONS}, selections={{"path":"/fare_class"}}, maxAllowedSelections=1) - submit (Button, child=submit_label, primary=true, action={{"name":"bookingSubmit","context":[{{"key":"formId","value":"booking"}}]}}) + submit (Button, child=submit_label, primary=true, action={{"name":"bookingSubmit","context":[ + {{"key":"formId","value":"booking"}}, + {{"key":"origin","value":{{"path":"/origin"}}}}, + {{"key":"dest","value":{{"path":"/dest"}}}}, + {{"key":"date","value":{{"path":"/date"}}}}, + {{"key":"passengers","value":{{"path":"/passengers"}}}}, + {{"key":"fare_class","value":{{"path":"/fare_class"}}}} + ]}}) submit_label (Text, text="Search flights") Use these exact ids.""" @@ -315,8 +320,14 @@ def _comp(id_: str, name: str, props: dict[str, Any]) -> A2uiComponent: _comp("fare", "MultipleChoice", {"label": "Fare class", "options": _FARE_OPTIONS, "selections": {"path": "/fare_class"}, "maxAllowedSelections": 1}), _comp("submit", "Button", {"child": "submit_label", "primary": True, - "action": {"name": "bookingSubmit", - "context": [{"key": "formId", "value": "booking"}]}}), + "action": {"name": "bookingSubmit", "context": [ + {"key": "formId", "value": "booking"}, + {"key": "origin", "value": {"path": "/origin"}}, + {"key": "dest", "value": {"path": "/dest"}}, + {"key": "date", "value": {"path": "/date"}}, + {"key": "passengers", "value": {"path": "/passengers"}}, + {"key": "fare_class", "value": {"path": "/fare_class"}}, + ]}}), _comp("submit_label", "Text", {"text": "Search flights"}), ], ) @@ -397,19 +408,37 @@ async def build_form(state: MessagesState) -> dict: ) +def _unwrap_literal(v: Any) -> Any: + """Unwrap a v0.9 literal wrapper ({literalString|literalNumber|literalBoolean: }).""" + if isinstance(v, dict): + for k in ("literalString", "literalNumber", "literalBoolean"): + if k in v: + return v[k] + return v + + def _parse_submit_payload(content: str) -> dict[str, Any] | None: - """Extract the form-data dict from an a2ui_event message content.""" + """Extract the form-data dict from a v0.9 A2uiActionMessage content. + + Chat-lib sends: + {"version":"v0.9","action":{"name":"...","surfaceId":"...", + "sourceComponentId":"...","timestamp":"...", + "context":{"formId":{"literalString":"booking"}, + "origin":{"literalString":"LAX"}, ...}}} + """ try: payload = json.loads(content) except (json.JSONDecodeError, TypeError): return None - if not isinstance(payload, dict) or payload.get("type") != "a2ui_event": + if not isinstance(payload, dict): + return None + action = payload.get("action") + if not isinstance(action, dict): return None - # Accept either {"data": {...}} or {"value": {...}} or context-level fields - data = payload.get("data") or payload.get("value") or {} - if not isinstance(data, dict): + ctx = action.get("context", {}) + if not isinstance(ctx, dict): return None - return data + return {k: _unwrap_literal(v) for k, v in ctx.items()} async def search_flights(state: MessagesState) -> dict: @@ -443,16 +472,17 @@ async def search_flights(state: MessagesState) -> dict: # ── Routing + compile ─────────────────────────────────────────────────────── def _is_submit_event(content: str) -> bool: - """True iff the content is an a2ui_event whose formId is 'booking'.""" + """True iff the content is a v0.9 A2uiActionMessage named bookingSubmit.""" try: payload = json.loads(content) except (json.JSONDecodeError, TypeError): return False + if not isinstance(payload, dict): + return False + action = payload.get("action") return ( - isinstance(payload, dict) - and payload.get("type") == "a2ui_event" - and isinstance(payload.get("context"), dict) - and payload["context"].get("formId") == "booking" + isinstance(action, dict) + and action.get("name") == "bookingSubmit" ) diff --git a/cockpit/langgraph/streaming/python/src/a2ui_graph.py b/cockpit/langgraph/streaming/python/src/a2ui_graph.py index 674c0c906..654a83283 100644 --- a/cockpit/langgraph/streaming/python/src/a2ui_graph.py +++ b/cockpit/langgraph/streaming/python/src/a2ui_graph.py @@ -248,7 +248,14 @@ async def _emit_with_retry( date (TextField, label="Departure date (YYYY-MM-DD)", text={{"path":"/date"}}, textFieldType="date") passengers (TextField, label="Passengers", text={{"path":"/passengers"}}, textFieldType="number") fare (MultipleChoice, label="Fare class", options={_FARE_OPTIONS}, selections={{"path":"/fare_class"}}, maxAllowedSelections=1) - submit (Button, child=submit_label, primary=true, action={{"name":"bookingSubmit","context":[{{"key":"formId","value":"booking"}}]}}) + submit (Button, child=submit_label, primary=true, action={{"name":"bookingSubmit","context":[ + {{"key":"formId","value":"booking"}}, + {{"key":"origin","value":{{"path":"/origin"}}}}, + {{"key":"dest","value":{{"path":"/dest"}}}}, + {{"key":"date","value":{{"path":"/date"}}}}, + {{"key":"passengers","value":{{"path":"/passengers"}}}}, + {{"key":"fare_class","value":{{"path":"/fare_class"}}}} + ]}}) submit_label (Text, text="Search flights") Use these exact ids.""" @@ -280,8 +287,14 @@ def _comp(id_: str, name: str, props: dict[str, Any]) -> A2uiComponent: _comp("fare", "MultipleChoice", {"label": "Fare class", "options": _FARE_OPTIONS, "selections": {"path": "/fare_class"}, "maxAllowedSelections": 1}), _comp("submit", "Button", {"child": "submit_label", "primary": True, - "action": {"name": "bookingSubmit", - "context": [{"key": "formId", "value": "booking"}]}}), + "action": {"name": "bookingSubmit", "context": [ + {"key": "formId", "value": "booking"}, + {"key": "origin", "value": {"path": "/origin"}}, + {"key": "dest", "value": {"path": "/dest"}}, + {"key": "date", "value": {"path": "/date"}}, + {"key": "passengers", "value": {"path": "/passengers"}}, + {"key": "fare_class", "value": {"path": "/fare_class"}}, + ]}}), _comp("submit_label", "Text", {"text": "Search flights"}), ], ) @@ -362,19 +375,37 @@ async def build_form(state: MessagesState) -> dict: ) +def _unwrap_literal(v: Any) -> Any: + """Unwrap a v0.9 literal wrapper ({literalString|literalNumber|literalBoolean: }).""" + if isinstance(v, dict): + for k in ("literalString", "literalNumber", "literalBoolean"): + if k in v: + return v[k] + return v + + def _parse_submit_payload(content: str) -> dict[str, Any] | None: - """Extract the form-data dict from an a2ui_event message content.""" + """Extract the form-data dict from a v0.9 A2uiActionMessage content. + + Chat-lib sends: + {"version":"v0.9","action":{"name":"...","surfaceId":"...", + "sourceComponentId":"...","timestamp":"...", + "context":{"formId":{"literalString":"booking"}, + "origin":{"literalString":"LAX"}, ...}}} + """ try: payload = json.loads(content) except (json.JSONDecodeError, TypeError): return None - if not isinstance(payload, dict) or payload.get("type") != "a2ui_event": + if not isinstance(payload, dict): + return None + action = payload.get("action") + if not isinstance(action, dict): return None - # Accept either {"data": {...}} or {"value": {...}} or context-level fields - data = payload.get("data") or payload.get("value") or {} - if not isinstance(data, dict): + ctx = action.get("context", {}) + if not isinstance(ctx, dict): return None - return data + return {k: _unwrap_literal(v) for k, v in ctx.items()} async def search_flights(state: MessagesState) -> dict: @@ -408,16 +439,17 @@ async def search_flights(state: MessagesState) -> dict: # ── Routing + compile ─────────────────────────────────────────────────────── def _is_submit_event(content: str) -> bool: - """True iff the content is an a2ui_event whose formId is 'booking'.""" + """True iff the content is a v0.9 A2uiActionMessage named bookingSubmit.""" try: payload = json.loads(content) except (json.JSONDecodeError, TypeError): return False + if not isinstance(payload, dict): + return False + action = payload.get("action") return ( - isinstance(payload, dict) - and payload.get("type") == "a2ui_event" - and isinstance(payload.get("context"), dict) - and payload["context"].get("formId") == "booking" + isinstance(action, dict) + and action.get("name") == "bookingSubmit" )