a4ui-agent 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,40 @@
1
+ # Local Entra token for the swel copilot (Lever A) — NEVER commit
2
+ .doc/swel/.copilot_token
3
+ .copilot_token
4
+
5
+ # Secrets / credentials
6
+ .env
7
+ .env.*
8
+ !.env.example
9
+ *.pem
10
+ *.key
11
+ *.pfx
12
+
13
+ # Python
14
+ __pycache__/
15
+ *.py[cod]
16
+ *.egg-info/
17
+ .venv/
18
+ venv/
19
+ .pytest_cache/
20
+
21
+ # Terraform (state + downloaded provider binaries are not source)
22
+ .terraform/
23
+ *.tfstate
24
+ *.tfstate.*
25
+ *.tfvars
26
+
27
+ # Binaries
28
+ *.exe
29
+ *.dll
30
+ *.so
31
+ *.dylib
32
+
33
+ # Node
34
+ node_modules/
35
+ dist/
36
+ build/
37
+
38
+ # OS / editor
39
+ .DS_Store
40
+ Thumbs.db
@@ -0,0 +1,37 @@
1
+ Metadata-Version: 2.5
2
+ Name: a4ui-agent
3
+ Version: 0.1.0
4
+ Summary: Agent-side A2UI chart runtime for @haystackedai/a4react clients. One function (answer(prompt)->rows,answer) gives an AgentCore agent a validated, drillable A2UI chart surface — no a2ui-core, catalog assembly, surface-message building, validation or rechart/drill wiring to write. Contract (catalogId + schemas) is baked, overridable.
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: a2ui-core<0.3.0,>=0.2.0
7
+ Requires-Dist: bedrock-agentcore>=1.9.1
8
+ Requires-Dist: pydantic>=2.7
9
+ Requires-Dist: strands-agents>=1.15.0
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest; extra == 'dev'
12
+ Requires-Dist: pytest-asyncio; extra == 'dev'
13
+ Description-Content-Type: text/markdown
14
+
15
+ # a4ui-agent
16
+
17
+ Agent-side A2UI chart runtime for [`@haystackedai/a4react`](../A4React) clients.
18
+
19
+ Write one function — `answer(prompt) -> (rows, columns, text)` — and
20
+ `make_a4ui_agent(answer)` returns a ready AgentCore app that turns a prompt into a
21
+ validated, drillable A2UI chart surface. No `a2ui-core`, catalog assembly, surface-message
22
+ building, validation, or rechart/drill wiring to write. The contract (catalogId + schemas)
23
+ is baked and overridable.
24
+
25
+ ```python
26
+ from a4ui_agent import make_a4ui_agent
27
+
28
+ def answer(prompt: str):
29
+ rows, columns, text = my_data_lookup(prompt)
30
+ return rows, columns, text
31
+
32
+ app = make_a4ui_agent(answer) # deterministic charts, zero config
33
+ if __name__ == "__main__":
34
+ app.run()
35
+ ```
36
+
37
+ See **instruction_agent.md** for the full DO list, LLM mode, and the contract rules.
@@ -0,0 +1,23 @@
1
+ # a4ui-agent
2
+
3
+ Agent-side A2UI chart runtime for [`@haystackedai/a4react`](../A4React) clients.
4
+
5
+ Write one function — `answer(prompt) -> (rows, columns, text)` — and
6
+ `make_a4ui_agent(answer)` returns a ready AgentCore app that turns a prompt into a
7
+ validated, drillable A2UI chart surface. No `a2ui-core`, catalog assembly, surface-message
8
+ building, validation, or rechart/drill wiring to write. The contract (catalogId + schemas)
9
+ is baked and overridable.
10
+
11
+ ```python
12
+ from a4ui_agent import make_a4ui_agent
13
+
14
+ def answer(prompt: str):
15
+ rows, columns, text = my_data_lookup(prompt)
16
+ return rows, columns, text
17
+
18
+ app = make_a4ui_agent(answer) # deterministic charts, zero config
19
+ if __name__ == "__main__":
20
+ app.run()
21
+ ```
22
+
23
+ See **instruction_agent.md** for the full DO list, LLM mode, and the contract rules.
@@ -0,0 +1,63 @@
1
+ # a4ui-agent
2
+
3
+ The agent-side twin of `@haystackedai/a4react`. a4react hides A2UI from the **frontend**
4
+ dev; this hides it from the **agent** dev.
5
+
6
+ *What the package gives you:*
7
+ - `make_a4ui_agent` — one call returns a ready AgentCore app (the whole turn: prompt → data → validated A2UI chart surface, streamed in the frames a4react parses)
8
+ - `Contract`, `DEFAULT_CONTRACT`, `DEFAULT_CATALOG_ID` — the baked contract + the type to override it
9
+ - `build_catalog`, `SurfaceEngine`, `build_series`, `pick_and_shape`, `format_answer` — lower-level blocks (only if you assemble it yourself)
10
+
11
+ *What it hides (you write none of these):*
12
+ - a2ui-core, catalog assembly, the A2UI_Chart schema, surface-message building, validation, rechart/drill routing, the AgentCore entrypoint.
13
+
14
+ *What it does NOT bring:*
15
+ - your data (you supply `answer()`), a deployment, or an LLM (optional — deterministic by default).
16
+
17
+ *What the agent dev must do:*
18
+
19
+ 1. Install:
20
+ `pip install a4ui-agent`
21
+
22
+ 2. Write ONE function — your only job — returning the rows for a prompt:
23
+ ```python
24
+ def answer(prompt: str):
25
+ rows, columns, text = my_data_lookup(prompt) # SQL, API, anything
26
+ return rows, columns, text # or (rows, text) · or {"rows":…, "columns":…, "answer":…}
27
+ ```
28
+ `rows` = list of dicts; `columns` optional (inferred from the first row); `text` = the prose answer. Sync or async both work.
29
+
30
+ 3. Build the agent — nothing else to stand up:
31
+ ```python
32
+ from a4ui_agent import make_a4ui_agent
33
+ app = make_a4ui_agent(answer)
34
+ # app = make_a4ui_agent(answer, model="global.anthropic.claude-sonnet-4-5-20250929-v1:0")
35
+ ```
36
+ No `model` = deterministic chart choice (zero config). A `model` id = the LLM picks chart type + column roles (your data still stays in code, never sent to the model).
37
+
38
+ 4. Run it (AgentCore):
39
+ ```python
40
+ if __name__ == "__main__":
41
+ app.run()
42
+ ```
43
+ The package handles `rechart` (layout-only re-emit) and `drill` (scoped re-query) actions automatically.
44
+
45
+ That is all. The chart renders in any `@haystackedai/a4react` client pointed at this agent — provided the contract byte-matches (step 5).
46
+
47
+ ## Contract — must byte-match the client
48
+ The package bakes `DEFAULT_CONTRACT` (catalogId + plotType enum + series shape), so a client on the **same** default just works. For a custom catalog, pass your own and keep BOTH ends in sync:
49
+ ```python
50
+ from a4ui_agent import make_a4ui_agent, Contract
51
+ make_a4ui_agent(answer, contract=Contract(
52
+ catalog_id="https://…/catalog.json", # == the client's catalogId
53
+ control_model=MyControl, # pydantic: has a plotType Literal
54
+ datashape_model=MySeries,
55
+ control_version="…", datashape_version="…",
56
+ ))
57
+ ```
58
+ Both ends are regenerated from one `A2UI_catalog_src` build — don't hand-edit either side.
59
+
60
+ ## If no chart shows
61
+ - **Blank client** — the client renders nothing until a surface arrives; it must `send()` a prompt AND reach this agent's endpoint. a4ui-agent is the agent half only.
62
+ - **"Catalog not found" on the client** — the baked contract here and the client's `catalogId` differ. Match them (step 5).
63
+ - **Deployment** — `app.run()` is the AgentCore runtime; it still has to be deployed where the client's `/api/v1/a2ui` gateway can invoke it.
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "a4ui-agent"
7
+ version = "0.1.0"
8
+ description = "Agent-side A2UI chart runtime for @haystackedai/a4react clients. One function (answer(prompt)->rows,answer) gives an AgentCore agent a validated, drillable A2UI chart surface — no a2ui-core, catalog assembly, surface-message building, validation or rechart/drill wiring to write. Contract (catalogId + schemas) is baked, overridable."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = [
12
+ "a2ui-core >= 0.2.0, < 0.3.0",
13
+ "bedrock-agentcore >= 1.9.1",
14
+ "strands-agents >= 1.15.0",
15
+ "pydantic >= 2.7",
16
+ ]
17
+
18
+ [project.optional-dependencies]
19
+ # The deterministic path needs none of these; dev installs what their answer() uses.
20
+ dev = ["pytest", "pytest-asyncio"]
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["src/a4ui_agent"]
@@ -0,0 +1,31 @@
1
+ # a4ui-agent — public surface.
2
+ #
3
+ # The agent-side twin of @haystackedai/a4react, WITH the runtime included. A4 hid the
4
+ # whole A2UI loop from the FRONTEND dev; this hides it from the AGENT dev. They write
5
+ # ONE function — answer(prompt) -> (rows, columns, text) — and get a ready AgentCore
6
+ # app that turns a prompt into a validated, drillable A2UI chart surface. No a2ui-core,
7
+ # no catalog assembly, no surface messages, no validation, no rechart/drill wiring.
8
+ #
9
+ # from a4ui_agent import make_a4ui_agent
10
+ # app = make_a4ui_agent(answer) # deterministic charts, zero config
11
+ # app = make_a4ui_agent(answer, model="…") # LLM-chosen charts
12
+ #
13
+ # The contract (catalogId + schemas) is baked (DEFAULT_CONTRACT) so install → it works;
14
+ # pass a custom Contract to match a client on a different catalog.
15
+
16
+ # The one-call runtime.
17
+ from ._runtime import make_a4ui_agent
18
+
19
+ # The contract: the baked default + the type for overriding it.
20
+ from ._contract import Contract, DEFAULT_CONTRACT, DEFAULT_CATALOG_ID
21
+
22
+ # Lower-level building blocks (for an agent that assembles the catalog/surface itself).
23
+ from ._catalog import build_catalog, BuiltCatalog
24
+ from ._surface import SurfaceEngine, build_series, pick_and_shape, format_answer
25
+
26
+ __all__ = [
27
+ "make_a4ui_agent",
28
+ "Contract", "DEFAULT_CONTRACT", "DEFAULT_CATALOG_ID",
29
+ "build_catalog", "BuiltCatalog",
30
+ "SurfaceEngine", "build_series", "pick_and_shape", "format_answer",
31
+ ]
@@ -0,0 +1,66 @@
1
+ """Assemble the A2UI catalog + validator from a Contract — the a2ui-core plumbing.
2
+
3
+ Hidden from the dev entirely. We take the contract's control/datashape models, build
4
+ the custom A2UI_Chart component against a2ui-core's CatalogComponentCommon, add it to
5
+ a2ui-core's BasicCatalog (v0_9), and build the PayloadValidator run on every surface
6
+ component before emit. This is the agent-side mirror of a4react's A4_makeCatalog.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import Literal, get_args
12
+
13
+ from pydantic import BaseModel, Field, create_model
14
+
15
+ from a2ui.core import PayloadValidator, STRICT_VALIDATION
16
+ from a2ui.core.basic_catalog.v0_9 import BasicCatalog
17
+ from a2ui.core.basic_catalog.v0_9.components import CatalogComponentCommon
18
+ from a2ui.core.catalog import ModelComponentApi
19
+ from a2ui.core.schema.v0_9.common_types import DataBinding, DynamicString
20
+
21
+ from ._contract import Contract
22
+
23
+
24
+ @dataclass
25
+ class BuiltCatalog:
26
+ """Everything the surface engine needs, derived once from the Contract."""
27
+ catalog_id: str
28
+ validator: PayloadValidator
29
+ series_model: type[BaseModel] # the datashape model (A2UIChartSeries)
30
+ plot_types: list[str]
31
+ schema_desc: str
32
+
33
+
34
+ def build_catalog(contract: Contract) -> BuiltCatalog:
35
+ """Build the full catalog (basic + A2UI_Chart) and its validator from the contract."""
36
+ # plotType vocabulary comes FROM the contract's control model — single-sourced.
37
+ plot_type_annotation = contract.control_model.model_fields["plotType"].annotation
38
+ series_model = contract.datashape_model
39
+
40
+ # Build the custom component dynamically from the injected schemas. create_model
41
+ # lets plotType/series be contract-driven types (a class body can't take type vars).
42
+ chart_component: type[BaseModel] = create_model(
43
+ "A2UIChartComponent",
44
+ __base__=CatalogComponentCommon,
45
+ component=(Literal["A2UI_Chart"], "A2UI_Chart"),
46
+ plotType=(plot_type_annotation, Field(..., description="The chart type to render.")),
47
+ title=(DynamicString | None, Field(None, description="Optional chart title.")),
48
+ series=(series_model | DataBinding, Field(
49
+ ..., description="The chart data (a {path} binding, or an inline series object).")),
50
+ )
51
+ chart_api = ModelComponentApi(chart_component)
52
+
53
+ # Start from a2ui-core's basic catalog (standard components); set our catalog id;
54
+ # add the custom A2UI_Chart → the full catalog.
55
+ catalog = BasicCatalog()
56
+ catalog.catalog_id = contract.catalog_id
57
+ catalog.components["A2UI_Chart"] = chart_api
58
+ validator = PayloadValidator(catalog, STRICT_VALIDATION)
59
+
60
+ plot_types = list(get_args(plot_type_annotation))
61
+ required = [n for n, f in chart_component.model_fields.items() if f.is_required()]
62
+ schema_desc = (
63
+ f"A2UI_Chart{{plotType∈{plot_types}, title?:DynamicString, "
64
+ f"series:A2UIChartSeries|DataBinding}} required={required}"
65
+ )
66
+ return BuiltCatalog(contract.catalog_id, validator, series_model, plot_types, schema_desc)
@@ -0,0 +1,42 @@
1
+ """The contract — catalogId + the two schema models + versions — INJECTED, not hard-coded.
2
+
3
+ The contract is the one thing the agent package and the frontend package
4
+ (@haystackedai/a4react) MUST byte-match: the `catalog_id` the client puts on
5
+ createSurface, the plotType vocabulary, and the series shape. The package ships a
6
+ DEFAULT_CONTRACT (the baked A2UI_catalog_src generation) so install → it works; a host
7
+ with its own catalog passes a custom Contract to make_a4ui_agent(..., contract=...).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from pydantic import BaseModel
13
+
14
+ from ._generated.chart_control import A2UIChartControl
15
+ from ._generated.chart_datashape import A2UIChartDataShape
16
+ from ._generated.contract_version import CONTROL_VERSION, DATASHAPE_VERSION
17
+
18
+ # The default catalog id. MUST byte-match the client's createSurface.catalogId
19
+ # (@haystackedai/a4react's catalogId / the frontend's A2UI_CATALOG_ID).
20
+ DEFAULT_CATALOG_ID = "https://voa.sanofi.com/a2ui/catalogs/voa/v0_9/catalog.json"
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Contract:
25
+ """What the agent and client agree on. `control_model` must expose a `plotType`
26
+ field (a Literal enum); `datashape_model` is the series shape. Both are Pydantic
27
+ models, mirroring @haystackedai/a4react's injected controlSchema/datashapeSchema."""
28
+ catalog_id: str
29
+ control_model: type[BaseModel]
30
+ datashape_model: type[BaseModel]
31
+ control_version: str
32
+ datashape_version: str
33
+
34
+
35
+ # The baked default — the A2UI_catalog_src generation this package ships.
36
+ DEFAULT_CONTRACT = Contract(
37
+ catalog_id=DEFAULT_CATALOG_ID,
38
+ control_model=A2UIChartControl,
39
+ datashape_model=A2UIChartDataShape,
40
+ control_version=CONTROL_VERSION,
41
+ datashape_version=DATASHAPE_VERSION,
42
+ )
@@ -0,0 +1,8 @@
1
+ """Baked contract artifacts — the package's DEFAULT contract.
2
+
3
+ GENERATED from A2UI_catalog_src/chart.*.json by A2UI_gen_pydantic.py. These are the
4
+ single-source contract pieces the agent and the frontend (@haystackedai/a4react) must
5
+ byte-match: the plotType vocabulary, the series shape, and the contract versions. The
6
+ package SHIPS them so a dev who installs it gets a working contract with no setup;
7
+ override via a4ui_agent.Contract only for a custom catalog.
8
+ """
@@ -0,0 +1,17 @@
1
+ # GENERATED from A2UI_catalog_src/chart.control.json by A2UI_gen_pydantic.py — DO NOT EDIT.
2
+ # Change the JSON and re-run the script to regenerate.
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Literal
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field
9
+
10
+
11
+ class A2UIChartControl(BaseModel):
12
+ model_config = ConfigDict(
13
+ extra='forbid',
14
+ )
15
+ plotType: Literal[
16
+ 'scatter', 'bar', 'column', 'line', 'area', 'pie', 'donut', 'KPI_CARD', 'table'
17
+ ] = Field(..., description='The chart type to render.')
@@ -0,0 +1,35 @@
1
+ # GENERATED from A2UI_catalog_src/chart.datashape.json by A2UI_gen_pydantic.py — DO NOT EDIT.
2
+ # Change the JSON and re-run the script to regenerate.
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Any
7
+
8
+ from pydantic import BaseModel, ConfigDict
9
+
10
+
11
+ class Point(BaseModel):
12
+ model_config = ConfigDict(
13
+ extra='forbid',
14
+ )
15
+ x: float
16
+ y: float
17
+
18
+
19
+ class Item(BaseModel):
20
+ model_config = ConfigDict(
21
+ extra='forbid',
22
+ )
23
+ label: str
24
+ value: float
25
+
26
+
27
+ class A2UIChartDataShape(BaseModel):
28
+ model_config = ConfigDict(
29
+ extra='forbid',
30
+ )
31
+ points: list[Point] | None = None
32
+ items: list[Item] | None = None
33
+ value: float | str | None = None
34
+ columns: list[str] | None = None
35
+ rows: list[dict[str, Any]] | None = None
@@ -0,0 +1,4 @@
1
+ # GENERATED from A2UI_catalog_src/chart.*.json by A2UI_gen_pydantic.py — DO NOT EDIT.
2
+ # Change the JSON and re-run the script to regenerate.
3
+ CONTROL_VERSION = "20261007.01"
4
+ DATASHAPE_VERSION = "20261007.01"
@@ -0,0 +1,56 @@
1
+ """System prompt + few-shot for LLM-generated A2UI surfaces — DATA-OUTSIDE variant.
2
+
3
+ Only used when make_a4ui_agent(..., model=...) opts into LLM chart composition. The
4
+ model composes the surface STRUCTURE and picks the chart, but never touches the data:
5
+ it sees only the question + column NAMES and returns {surface, roles}. Code then fills
6
+ updateDataModel (series from the real rows, note from the answer) behind the {path}
7
+ bindings — so the dev's values reach the chart unaltered.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+
13
+ # The exact OUTPUT shape the model must return: the 2 structure messages (NO data,
14
+ # NO updateDataModel) + a roles map. Code appends updateDataModel afterwards.
15
+ _ROLE = (
16
+ "You are a data-visualization agent. Given a user question and the COLUMN NAMES of a "
17
+ "SQL result, you compose ONE A2UI chart surface. You never receive or emit the data "
18
+ "values — the system fills those in from the real SQL rows behind the {path} bindings."
19
+ )
20
+
21
+ _RULES = """Output ONLY a JSON object (no prose, no code fences) with exactly two keys:
22
+
23
+ "surface": an array of exactly TWO A2UI messages — createSurface and updateComponents —
24
+ copied EXACTLY from the example (same surfaceId, same catalogId, same component
25
+ tree with Text bound to /note and A2UI_Chart bound to /series). Change ONLY the
26
+ A2UI_Chart `plotType`.
27
+ "roles": which columns feed the chart, chosen EXACTLY from the provided column names.
28
+
29
+ Do NOT emit updateDataModel, data values, series arrays, or note text — the system adds
30
+ updateDataModel (series from the SQL rows, note from the ANSWER) itself.
31
+
32
+ Pick `plotType` to fit the data, and set the matching roles (null for unused):
33
+ * category + number -> "column" (few) or "bar" (many/long labels/ranking); set label_column + value_column
34
+ * one row, one number -> "KPI_CARD"; set value_column
35
+ * time + number -> "line" (trend) or "area"; set x_column=time col + value_column
36
+ * parts of a whole (<=8) -> "pie" / "donut"; set label_column + value_column
37
+ * two numeric columns -> "scatter"; set x_column + y_column
38
+ * doesn't fit a chart -> "table"; roles may be null"""
39
+
40
+
41
+ def build_system_prompt(catalog_id: str) -> str:
42
+ """Role + rules + the exact output example (structure + roles, no data)."""
43
+ fewshot_output = {
44
+ "surface": [
45
+ {"version": "v0.9", "createSurface": {"surfaceId": "A2UI_dev", "catalogId": catalog_id}},
46
+ {"version": "v0.9", "updateComponents": {"surfaceId": "A2UI_dev", "components": [
47
+ {"id": "root", "component": "Column", "children": ["n", "c"]},
48
+ {"id": "n", "component": "Text", "text": {"path": "/note"}},
49
+ {"id": "c", "component": "A2UI_Chart", "plotType": "bar", "series": {"path": "/series"}},
50
+ ]}},
51
+ ],
52
+ "roles": {"label_column": "ACTIVITY_SCHEDULE_STATUS", "value_column": "ACTIVITY_COUNT",
53
+ "x_column": None, "y_column": None},
54
+ }
55
+ example = json.dumps(fewshot_output, indent=2)
56
+ return f"{_ROLE}\n\n{_RULES}\n\n---BEGIN EXAMPLE OUTPUT---\n{example}\n---END EXAMPLE OUTPUT---"
@@ -0,0 +1,159 @@
1
+ """make_a4ui_agent — the full agent runtime, packaged.
2
+
3
+ This is what the package adds over a surface-builder library: the dev no longer wires
4
+ a catalog, builds surface messages, validates, handles rechart/drill, or hand-rolls the
5
+ AgentCore entrypoint. They write ONE function — answer(prompt) -> (rows, columns, text)
6
+ — and get a ready BedrockAgentCoreApp whose turn mirrors nslswel's: prompt → data →
7
+ A2UI surface, streamed in the exact frames @haystackedai/a4react parses.
8
+
9
+ from a4ui_agent import make_a4ui_agent
10
+
11
+ def answer(prompt: str):
12
+ rows, columns, text = my_data_lookup(prompt)
13
+ return rows, columns, text
14
+
15
+ app = make_a4ui_agent(answer) # deterministic charts, zero config
16
+ # app = make_a4ui_agent(answer, model="global.anthropic.claude-sonnet-4-5-...") # LLM-chosen charts
17
+ if __name__ == "__main__":
18
+ app.run()
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import inspect
24
+ import json
25
+
26
+ from ._catalog import build_catalog
27
+ from ._contract import Contract, DEFAULT_CONTRACT
28
+ from ._surface import SurfaceEngine, format_answer, pick_and_shape
29
+ from ._trace import trace_frame
30
+
31
+
32
+ def _normalize_dataset(res) -> dict:
33
+ """Accept the dev's answer() return in any ergonomic form and normalize to
34
+ {ok, rows, columns, answer, error}. Columns are inferred from the first row if
35
+ the dev didn't supply them."""
36
+ rows = columns = answer = error = None
37
+ if isinstance(res, dict):
38
+ rows, columns, answer, error = res.get("rows"), res.get("columns"), res.get("answer"), res.get("error")
39
+ elif isinstance(res, (tuple, list)):
40
+ if len(res) == 3:
41
+ rows, columns, answer = res
42
+ elif len(res) == 2:
43
+ rows, answer = res
44
+ elif res:
45
+ rows = res[0]
46
+ else:
47
+ rows = res
48
+ rows = list(rows or [])
49
+ if not columns:
50
+ columns = list(rows[0].keys()) if rows and isinstance(rows[0], dict) else []
51
+ return {"ok": bool(rows), "rows": rows, "columns": list(columns or []),
52
+ "answer": answer or "", "error": error}
53
+
54
+
55
+ async def _call_answer(answer, prompt: str) -> dict:
56
+ """Call the dev's answer() — async or sync (sync runs off-thread) — and normalize."""
57
+ if inspect.iscoroutinefunction(answer):
58
+ res = await answer(prompt)
59
+ else:
60
+ res = await asyncio.to_thread(answer, prompt)
61
+ if inspect.isawaitable(res):
62
+ res = await res
63
+ return _normalize_dataset(res)
64
+
65
+
66
+ async def _run_turn(payload, context, *, engine: SurfaceEngine, answer, contract: Contract, use_llm: bool):
67
+ """The data turn: handle rechart/version/drill actions, else prompt → data → surface."""
68
+ name = engine.agent_name
69
+ action = payload.get("action") if isinstance(payload, dict) else None
70
+
71
+ # rechart — layout-only re-emit; the client already holds the data model.
72
+ if action and action.get("name") == "rechart":
73
+ plot_type = (action.get("context") or {}).get("plotType", "bar")
74
+ yield trace_frame(name, "rechart action — layout-only re-emit (no data)",
75
+ f"action={json.dumps(action)} → plotType={plot_type}")
76
+ yield {"kind": "a2ui", "a2ui_messages": [engine.rechart_message(plot_type)], "usage": {}}
77
+ return
78
+
79
+ # version — contract handshake; the client compares to its baked version.
80
+ if action and action.get("name") == "version":
81
+ yield {"kind": "version", "version": {"control": contract.control_version,
82
+ "datashape": contract.datashape_version}}
83
+ return
84
+
85
+ session_id = getattr(context, "session_id", "a2ui-session")
86
+ prompt = payload.get("prompt", "") if isinstance(payload, dict) else ""
87
+
88
+ # drill — a scoped NEW data turn. The click carries the clicked value + the original
89
+ # question (no server memory); compose a scoped follow-up and run it below.
90
+ if action and action.get("name") == "drill":
91
+ ctx = action.get("context") or {}
92
+ value, question = ctx.get("value") or "", ctx.get("question") or ""
93
+ prompt = (f"{question}\n\nNow focus ONLY on '{value}' and show its breakdown one level "
94
+ f"deeper (e.g. by phase, status, or sub-category) as a chart.")
95
+ yield trace_frame(name, f"drill action → scoped new query for '{value}'", f"composed prompt: {prompt!r}")
96
+
97
+ yield trace_frame(f"{name} → data source", "fetch the data for the prompt", f"prompt={prompt!r}")
98
+ dataset = await _call_answer(answer, prompt)
99
+
100
+ if dataset["ok"] and dataset["rows"]:
101
+ yield trace_frame(f"data source → {name}",
102
+ f"returned {len(dataset['rows'])} rows ({len(dataset['columns'])} cols)",
103
+ json.dumps(dataset["rows"][:10], default=str))
104
+ if use_llm:
105
+ yield trace_frame("orchestrator", "split data — columns → LLM, rows → code",
106
+ f"LLM path: columns={dataset['columns']}\n"
107
+ f"DATA path: {json.dumps(dataset['rows'][:10], default=str)}")
108
+ try:
109
+ async for frame in engine.generate(prompt, dataset):
110
+ yield frame
111
+ return
112
+ except Exception as exc:
113
+ yield trace_frame(name, "LLM generate failed → deterministic fallback", str(exc))
114
+ plot_type, series = pick_and_shape(dataset)
115
+ messages = engine.build_surface(plot_type, series, format_answer(dataset["answer"]))
116
+ else:
117
+ plot_type, series = pick_and_shape(dataset)
118
+ messages = engine.build_surface(plot_type, series, format_answer(dataset["answer"]))
119
+ else:
120
+ yield trace_frame(f"data source → {name}", "no rows / error", dataset.get("error") or "")
121
+ messages = engine.build_surface("bar", {"items": []}, format_answer(dataset["answer"]))
122
+
123
+ yield trace_frame(name, "emit A2UI surface", f"{len(messages)} messages")
124
+ yield {"kind": "a2ui", "a2ui_messages": messages, "usage": {}}
125
+
126
+
127
+ def make_a4ui_agent(answer, *, contract: Contract = DEFAULT_CONTRACT, model=None,
128
+ agent_name: str = "a4ui chart agent", chart_type_buttons=None):
129
+ """Build the ready AgentCore app from the dev's answer() function.
130
+
131
+ answer(prompt) -> (rows, columns, answer_text) | (rows, answer_text)
132
+ | {"rows":..., "columns":..., "answer":...}; sync or async.
133
+ model -> None = deterministic charts (zero config). A model_id str or a
134
+ Strands model = LLM-composed charts (chart type/roles chosen by
135
+ the model; data stays in code).
136
+ contract -> defaults to the baked contract; pass a custom Contract to match
137
+ a client on a different catalog.
138
+ """
139
+ from bedrock_agentcore.runtime import BedrockAgentCoreApp
140
+
141
+ use_llm = model is not None
142
+ resolved_model = model
143
+ if isinstance(model, str):
144
+ from strands.models.bedrock import BedrockModel
145
+ resolved_model = BedrockModel(model_id=model)
146
+
147
+ built = build_catalog(contract)
148
+ engine = SurfaceEngine(built, model=resolved_model, agent_name=agent_name,
149
+ chart_type_buttons=chart_type_buttons)
150
+
151
+ app = BedrockAgentCoreApp()
152
+
153
+ @app.entrypoint
154
+ async def invoke(payload, context):
155
+ async for frame in _run_turn(payload, context, engine=engine, answer=answer,
156
+ contract=contract, use_llm=use_llm):
157
+ yield frame
158
+
159
+ return app
@@ -0,0 +1,251 @@
1
+ """Chart/surface producer — turns (rows, columns, answer) into A2UI wire messages.
2
+
3
+ This is the heart of what the package hides. Two paths:
4
+
5
+ PRIMARY (model set): LLM composes the surface STRUCTURE + picks plotType + column roles
6
+ from the question + COLUMN NAMES only (never the rows); then CODE fills updateDataModel
7
+ (series from the real rows, note from the answer) behind the {path} bindings. The model
8
+ never sees a data value, so the dev's numbers reach the chart unaltered.
9
+
10
+ DETERMINISTIC (no model): pick_and_shape() + build_surface() build a surface with no LLM
11
+ — the zero-config default so install → chart.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import re
17
+
18
+ from ._catalog import BuiltCatalog
19
+ from ._prompt import build_system_prompt
20
+ from ._trace import trace_frame
21
+
22
+ # Chart types offered as a toggle row under the chart. All share the same label/value
23
+ # series, so switching is layout-only (rechart action → updateComponents, same /series).
24
+ # v0.9 Button uses `child` (a Text node) for its label + action.event.{name,context}.
25
+ DEFAULT_CHART_TYPE_BUTTONS = [
26
+ ("donut", "Donut"), ("pie", "Pie"), ("bar", "Bar"), ("column", "Column"), ("table", "Table"),
27
+ ]
28
+
29
+ _SURFACE_ID = "A2UI_dev"
30
+
31
+
32
+ # ── pure data helpers (plotType vocabulary is the standard chart set) ──────────────
33
+ def format_answer(text: str) -> str:
34
+ """Normalize prose into clean, renderable markdown: blank line around a
35
+ '**... Insights:**' header and each spaced-hyphen bullet on its own line."""
36
+ if not text:
37
+ return text
38
+ t = text.strip()
39
+ t = re.sub(r"\s*\*\*([^*]*Insights[^*]*)\*\*\s*", r"\n\n**\1**\n\n", t, flags=re.I)
40
+ t = re.sub(r"[ \t]+-[ \t]+", "\n- ", t)
41
+ return t.strip()
42
+
43
+
44
+ def _col(columns: list, name) -> str | None:
45
+ """Case-insensitive column match (SQL engines often upper-case)."""
46
+ if name:
47
+ for c in columns:
48
+ if str(c).lower() == str(name).lower():
49
+ return c
50
+ return None
51
+
52
+
53
+ def build_series(intent: dict, dataset: dict):
54
+ """Build the chart series from the chosen plotType + column roles + the REAL rows.
55
+ Every value is copied straight from dataset['rows'] — never recomputed. Returns
56
+ (plot_type, series); may fall back to 'table' if the roles don't resolve."""
57
+ cols = dataset.get("columns") or []
58
+ rows = dataset.get("rows") or []
59
+ pt = intent.get("plotType")
60
+ if pt == "table":
61
+ return "table", {"columns": cols, "rows": rows[:50]}
62
+ if pt in ("line", "area"):
63
+ xc = _col(cols, intent.get("x_column")) or _col(cols, intent.get("label_column"))
64
+ vc = _col(cols, intent.get("value_column"))
65
+ if xc and vc:
66
+ return pt, {"items": [{"label": str(r.get(xc)), "value": r.get(vc)} for r in rows[:100]]}
67
+ if pt in ("pie", "donut"):
68
+ lc, vc = _col(cols, intent.get("label_column")), _col(cols, intent.get("value_column"))
69
+ if lc and vc:
70
+ return pt, {"items": [{"label": str(r.get(lc)), "value": r.get(vc)} for r in rows[:12]]}
71
+ if pt == "column":
72
+ lc, vc = _col(cols, intent.get("label_column")), _col(cols, intent.get("value_column"))
73
+ if lc and vc:
74
+ return "column", {"items": [{"label": str(r.get(lc)), "value": r.get(vc)} for r in rows[:100]]}
75
+ if pt == "KPI_CARD":
76
+ vc = _col(cols, intent.get("value_column")) or (cols[0] if cols else None)
77
+ return "KPI_CARD", {"value": rows[0].get(vc) if rows and vc else None}
78
+ if pt == "scatter":
79
+ xc, yc = _col(cols, intent.get("x_column")), _col(cols, intent.get("y_column"))
80
+ if xc and yc:
81
+ return "scatter", {"points": [{"x": r.get(xc), "y": r.get(yc)} for r in rows[:150]]}
82
+ lc, vc = _col(cols, intent.get("label_column")), _col(cols, intent.get("value_column"))
83
+ if not (lc and vc):
84
+ return "table", {"columns": cols, "rows": rows[:50]} # honest fallback: show rows, don't fake a bar
85
+ return "bar", {"items": [{"label": str(r.get(lc)), "value": r.get(vc)} for r in rows[:100]]}
86
+
87
+
88
+ def pick_and_shape(dataset: dict):
89
+ """Infer a chart type + series from column shapes (no LLM, no roles)."""
90
+ cols = dataset.get("columns") or []
91
+ rows = dataset.get("rows") or []
92
+ isnum = lambda v: isinstance(v, (int, float)) and not isinstance(v, bool)
93
+ nums = [c for c in cols if any(isnum(r.get(c)) for r in rows)]
94
+ cats = [c for c in cols if c not in nums]
95
+ if len(rows) == 1 and nums: # single_value → KPI
96
+ return "KPI_CARD", {"value": rows[0].get(nums[0])}
97
+ if len(nums) >= 2 and not cats: # xy_point → scatter
98
+ x, y = nums[0], nums[1]
99
+ return "scatter", {"points": [{"x": r.get(x), "y": r.get(y)} for r in rows[:150]]}
100
+ val = nums[0] if nums else (cols[1] if len(cols) > 1 else (cols[0] if cols else None)) # label_value → bar
101
+ lab = next((c for c in cols if c != val), (cols[0] if cols else None))
102
+ items = [{"label": str(r.get(lab)), "value": r.get(val)} for r in rows[:100]] if (lab and val) else []
103
+ return "bar", {"items": items}
104
+
105
+
106
+ class SurfaceEngine:
107
+ """Builds + validates A2UI surfaces for one Contract. Holds the catalog_id, the
108
+ validator and the series model so the surface functions stay contract-bound."""
109
+
110
+ def __init__(self, built: BuiltCatalog, *, model=None, agent_name="a4ui chart agent",
111
+ chart_type_buttons=None):
112
+ self._built = built
113
+ self.catalog_id = built.catalog_id
114
+ self.validator = built.validator
115
+ self.series_model = built.series_model
116
+ self.schema_desc = built.schema_desc
117
+ self.model = model
118
+ self.agent_name = agent_name
119
+ self.chart_type_buttons = chart_type_buttons or DEFAULT_CHART_TYPE_BUTTONS
120
+
121
+ # ── surface structure ─────────────────────────────────────────────────────────
122
+ def _button_row(self):
123
+ ids, nodes = [], []
124
+ for pt, label in self.chart_type_buttons:
125
+ bid, lid = f"btn_{pt}", f"lbl_{pt}"
126
+ nodes.append({"id": bid, "component": "Button", "child": lid,
127
+ "action": {"event": {"name": "rechart", "context": {"plotType": pt}}}})
128
+ nodes.append({"id": lid, "component": "Text", "text": label})
129
+ ids.append(bid)
130
+ return ids, nodes
131
+
132
+ def _components(self, plot_type: str):
133
+ """Canonical surface tree: chart on top, a toggle Row of chart-type buttons, then the note."""
134
+ btn_ids, btn_nodes = self._button_row()
135
+ return [
136
+ {"id": "root", "component": "Column", "children": ["c", "btns", "n"]},
137
+ {"id": "c", "component": "A2UI_Chart", "plotType": plot_type, "series": {"path": "/series"}},
138
+ {"id": "btns", "component": "Row", "children": btn_ids},
139
+ {"id": "n", "component": "Text", "text": {"path": "/note"}},
140
+ *btn_nodes,
141
+ ]
142
+
143
+ def rechart_message(self, plot_type: str) -> dict:
144
+ """Layout-only update for the rechart action: same tree, new plotType, same
145
+ bindings (no createSurface, no data — the client already holds the data model)."""
146
+ return {"version": "v0.9", "updateComponents": {"surfaceId": _SURFACE_ID,
147
+ "components": self._components(plot_type)}}
148
+
149
+ def build_surface(self, plot_type: str, series: dict, note: str) -> list[dict]:
150
+ """Assemble the 3-message surface (createSurface + updateComponents + updateDataModel)."""
151
+ return [
152
+ {"version": "v0.9", "createSurface": {"surfaceId": _SURFACE_ID, "catalogId": self.catalog_id}},
153
+ {"version": "v0.9", "updateComponents": {"surfaceId": _SURFACE_ID, "components": self._components(plot_type)}},
154
+ {"version": "v0.9", "updateDataModel": {"surfaceId": _SURFACE_ID, "path": "/",
155
+ "value": {"note": note, "series": series}}},
156
+ ]
157
+
158
+ # ── validation guardrail ────────────────────────────────────────────────────
159
+ def _validate_surface(self, messages: list) -> list[str]:
160
+ """Every component on-catalog/valid, and the code-built series matches the
161
+ A2UI_Chart series shape. Returns a per-item description for the trace."""
162
+ checked: list[str] = []
163
+ for msg in messages:
164
+ for comp in (msg.get("updateComponents") or {}).get("components") or []:
165
+ self.validator.validate_component(comp)
166
+ d = f"{comp.get('component')}#{comp.get('id')}"
167
+ if comp.get("component") == "A2UI_Chart":
168
+ d += f"(plotType={comp.get('plotType')!r})"
169
+ checked.append(d)
170
+ series = ((msg.get("updateDataModel") or {}).get("value") or {}).get("series")
171
+ if series is not None:
172
+ self.series_model.model_validate(series)
173
+ shape = next((f"{k}×{len(series[k])}" for k in ("items", "points", "rows")
174
+ if series.get(k) is not None), "value")
175
+ checked.append(f"series:{shape}")
176
+ return checked
177
+
178
+ # ── PRIMARY: LLM-generated, data-outside ──────────────────────────────────────
179
+ @staticmethod
180
+ def _extract_object(text: str) -> dict:
181
+ t = (text or "").strip()
182
+ m = re.search(r"```(?:json)?\s*(.*?)```", t, re.S)
183
+ if m:
184
+ t = m.group(1).strip()
185
+ s, e = t.find("{"), t.rfind("}")
186
+ if s != -1 and e != -1:
187
+ t = t[s:e + 1]
188
+ return json.loads(t)
189
+
190
+ @staticmethod
191
+ def _find_chart(components: list) -> dict:
192
+ for c in components:
193
+ if c.get("component") == "A2UI_Chart":
194
+ return c
195
+ raise ValueError("generated surface has no A2UI_Chart component")
196
+
197
+ @staticmethod
198
+ def _usage_from_result(result) -> dict:
199
+ try:
200
+ u = result.metrics.accumulated_usage
201
+ return {"input_tokens": int(u.get("inputTokens", 0)), "output_tokens": int(u.get("outputTokens", 0))}
202
+ except Exception:
203
+ return {}
204
+
205
+ async def generate(self, prompt: str, dataset: dict):
206
+ """Async generator: yields a trace frame per sub-step, then the final a2ui frame.
207
+ The model composes STRUCTURE + roles from the question + COLUMN NAMES; CODE fills
208
+ the data. One retry; raises if still invalid (the runtime catches → fallback)."""
209
+ from strands import Agent
210
+ from strands.agent.conversation_manager.null_conversation_manager import NullConversationManager
211
+
212
+ agent = Agent(model=self.model, system_prompt=build_system_prompt(self.catalog_id),
213
+ conversation_manager=NullConversationManager())
214
+ user_msg = f"Question: {prompt}\nColumns: {dataset.get('columns')}"
215
+ note = format_answer(dataset.get("answer") or "")
216
+ last_err = None
217
+ for attempt in range(2):
218
+ msg = user_msg if attempt == 0 else (
219
+ f"{user_msg}\n\nYour previous output was INVALID: {last_err}\n"
220
+ "Return ONLY the corrected JSON object with keys 'surface' and 'roles'."
221
+ )
222
+ result = await agent.invoke_async(msg)
223
+ try:
224
+ obj = self._extract_object(str(result))
225
+ surface = obj["surface"]
226
+ roles = obj.get("roles") or {}
227
+ comps = surface[1]["updateComponents"]["components"]
228
+ chart = self._find_chart(comps)
229
+ sid = surface[0]["createSurface"]["surfaceId"]
230
+ plot_type, series = build_series({**roles, "plotType": chart.get("plotType")}, dataset)
231
+ chart["plotType"] = plot_type
232
+ surface[1]["updateComponents"]["components"] = self._components(plot_type)
233
+ messages = surface + [{"version": "v0.9", "updateDataModel": {
234
+ "surfaceId": sid, "path": "/", "value": {"note": note, "series": series}}}]
235
+ yield trace_frame("A2UI surface", "composed A2UI surface → validate via A2UI catalog",
236
+ f"LLM: plotType={plot_type}, roles={roles}\n"
237
+ f"DATA path: series = {json.dumps(series, default=str)}")
238
+ checked = self._validate_surface(messages)
239
+ yield trace_frame("A2UI catalog", "validated against the A2UI catalog",
240
+ f"schema {self.schema_desc} · checked: {', '.join(checked)} → all OK")
241
+ _emitted = "\n".join(
242
+ f"{next((k for k in ('createSurface', 'updateComponents', 'updateDataModel') if k in m), 'message')}: "
243
+ f"{json.dumps(m, default=str)}" for m in messages)
244
+ yield trace_frame("A2UI surface", "emit A2UI messages → A2UI client creates the surface",
245
+ "agent emits the surface SPEC; the client's processMessages creates it:\n" + _emitted)
246
+ yield {"kind": "a2ui", "a2ui_messages": messages, "usage": self._usage_from_result(result)}
247
+ return
248
+ except Exception as exc:
249
+ last_err = str(exc)[:300]
250
+ yield trace_frame("A2UI catalog", "validation failed — retrying" if attempt == 0 else "validation failed", last_err)
251
+ raise ValueError(f"LLM surface generation failed after retry: {last_err}")
@@ -0,0 +1,20 @@
1
+ """Flow-trace frame builder. One entry per pipeline step → forwarded to the browser
2
+ flow panel (@haystackedai/a4react's RightPane reads these). Part of the TRANSPORT
3
+ contract the frontend package parses, so the frame shape is frozen here."""
4
+ from __future__ import annotations
5
+
6
+ import time
7
+
8
+ # Cap each detail string so a frame stays under the AgentCore event-size limit.
9
+ # Large enough to show the emitted A2UI surface (3 messages) in full.
10
+ TRACE_DETAIL_CAP = 3000
11
+
12
+
13
+ def trace_frame(actor: str, action: str, detail: str = "") -> dict:
14
+ """Build one 'trace' frame (kind:'trace'); the gateway re-wraps it as a meta part."""
15
+ return {"kind": "trace", "entry": {
16
+ "ts": int(time.time() * 1000),
17
+ "actor": actor,
18
+ "action": action,
19
+ "detail": str(detail)[:TRACE_DETAIL_CAP],
20
+ }}
@@ -0,0 +1,53 @@
1
+ """Deterministic-path smoke test: no AWS, no model, no AgentCore — just prove the
2
+ baked contract assembles a valid catalog and that a built surface passes the a2ui-core
3
+ validator. Run with a Python that has a2ui-core installed:
4
+
5
+ python tests/test_surface.py # plain asserts
6
+ pytest tests/test_surface.py # if pytest present
7
+ """
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
12
+
13
+ from a4ui_agent import build_catalog, DEFAULT_CONTRACT, SurfaceEngine, pick_and_shape, format_answer
14
+
15
+ DATASET = {
16
+ "columns": ["STATUS", "CNT"],
17
+ "rows": [{"STATUS": "Active", "CNT": 5}, {"STATUS": "Closed", "CNT": 3}],
18
+ "answer": "Two statuses - Active and Closed.",
19
+ }
20
+
21
+
22
+ def test_deterministic_surface_validates():
23
+ built = build_catalog(DEFAULT_CONTRACT)
24
+ engine = SurfaceEngine(built)
25
+
26
+ plot_type, series = pick_and_shape(DATASET)
27
+ assert plot_type in built.plot_types
28
+ assert series.get("items") # category + number → bar with items
29
+
30
+ messages = engine.build_surface(plot_type, series, format_answer(DATASET["answer"]))
31
+ assert len(messages) == 3
32
+ assert messages[0]["createSurface"]["catalogId"] == DEFAULT_CONTRACT.catalog_id
33
+
34
+ checked = engine._validate_surface(messages) # raises if any component is off-catalog
35
+ assert any("A2UI_Chart" in c for c in checked)
36
+ assert any(c.startswith("series:") for c in checked)
37
+ return plot_type, checked
38
+
39
+
40
+ def test_rechart_message_is_layout_only():
41
+ engine = SurfaceEngine(build_catalog(DEFAULT_CONTRACT))
42
+ msg = engine.rechart_message("donut")
43
+ assert "createSurface" not in msg and "updateComponents" in msg
44
+ chart = next(c for c in msg["updateComponents"]["components"] if c.get("component") == "A2UI_Chart")
45
+ assert chart["plotType"] == "donut"
46
+
47
+
48
+ if __name__ == "__main__":
49
+ pt, checked = test_deterministic_surface_validates()
50
+ test_rechart_message_is_layout_only()
51
+ print("plotType:", pt)
52
+ print("validated:", checked)
53
+ print("ALL OK")