ag-ui-a2ui-toolkit 0.0.1__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,36 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ag-ui-a2ui-toolkit
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Framework-agnostic helpers for building A2UI subagent tools — op builders, prompt assembly, history walkers, and request/envelope orchestration shared across framework adapters.
|
|
5
|
+
Author: Ran Shem Tov
|
|
6
|
+
Author-email: Ran Shem Tov <ran@copilotkit.ai>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Requires-Python: >=3.10, <3.15
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# ag-ui-a2ui-toolkit
|
|
12
|
+
|
|
13
|
+
Framework-agnostic helpers for building A2UI subagent tools.
|
|
14
|
+
|
|
15
|
+
Each per-framework adapter (LangGraph, ADK, Mastra, …) composes these helpers
|
|
16
|
+
with its own framework-specific glue: tool decorator, runtime accessor, model
|
|
17
|
+
binding + invoke. Nothing in this package depends on any agent framework.
|
|
18
|
+
|
|
19
|
+
## Surface
|
|
20
|
+
|
|
21
|
+
- Constants: `A2UI_OPERATIONS_KEY`, `BASIC_CATALOG_ID`, `DEFAULT_SURFACE_ID`,
|
|
22
|
+
`GENERATE_A2UI_TOOL_NAME`, `GENERATE_A2UI_TOOL_DESCRIPTION`,
|
|
23
|
+
`GENERATE_A2UI_ARG_DESCRIPTIONS`
|
|
24
|
+
- Op builders: `create_surface`, `update_components`, `update_data_model`
|
|
25
|
+
- `RENDER_A2UI_TOOL_DEF` — JSON schema for the inner structured-output tool
|
|
26
|
+
- State + history helpers: `build_context_prompt`, `find_prior_surface`
|
|
27
|
+
- Prompt composer: `build_subagent_prompt`
|
|
28
|
+
- High-level orchestration: `prepare_a2ui_request`, `build_a2ui_envelope`
|
|
29
|
+
- Output wrappers: `assemble_ops`, `wrap_as_operations_envelope`,
|
|
30
|
+
`wrap_error_envelope`
|
|
31
|
+
|
|
32
|
+
## See also
|
|
33
|
+
|
|
34
|
+
The TypeScript counterpart lives in
|
|
35
|
+
[`@ag-ui/a2ui-toolkit`](../../typescript/packages/a2ui-toolkit) and exposes the
|
|
36
|
+
same surface in camelCase.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# ag-ui-a2ui-toolkit
|
|
2
|
+
|
|
3
|
+
Framework-agnostic helpers for building A2UI subagent tools.
|
|
4
|
+
|
|
5
|
+
Each per-framework adapter (LangGraph, ADK, Mastra, …) composes these helpers
|
|
6
|
+
with its own framework-specific glue: tool decorator, runtime accessor, model
|
|
7
|
+
binding + invoke. Nothing in this package depends on any agent framework.
|
|
8
|
+
|
|
9
|
+
## Surface
|
|
10
|
+
|
|
11
|
+
- Constants: `A2UI_OPERATIONS_KEY`, `BASIC_CATALOG_ID`, `DEFAULT_SURFACE_ID`,
|
|
12
|
+
`GENERATE_A2UI_TOOL_NAME`, `GENERATE_A2UI_TOOL_DESCRIPTION`,
|
|
13
|
+
`GENERATE_A2UI_ARG_DESCRIPTIONS`
|
|
14
|
+
- Op builders: `create_surface`, `update_components`, `update_data_model`
|
|
15
|
+
- `RENDER_A2UI_TOOL_DEF` — JSON schema for the inner structured-output tool
|
|
16
|
+
- State + history helpers: `build_context_prompt`, `find_prior_surface`
|
|
17
|
+
- Prompt composer: `build_subagent_prompt`
|
|
18
|
+
- High-level orchestration: `prepare_a2ui_request`, `build_a2ui_envelope`
|
|
19
|
+
- Output wrappers: `assemble_ops`, `wrap_as_operations_envelope`,
|
|
20
|
+
`wrap_error_envelope`
|
|
21
|
+
|
|
22
|
+
## See also
|
|
23
|
+
|
|
24
|
+
The TypeScript counterpart lives in
|
|
25
|
+
[`@ag-ui/a2ui-toolkit`](../../typescript/packages/a2ui-toolkit) and exposes the
|
|
26
|
+
same surface in camelCase.
|
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ag-ui-a2ui-toolkit
|
|
3
|
+
==================
|
|
4
|
+
|
|
5
|
+
Framework-agnostic building blocks for A2UI subagent tools. Each per-
|
|
6
|
+
framework adapter (LangGraph, ADK, Mastra, …) composes these helpers with its
|
|
7
|
+
framework-specific glue (tool decorator, runtime accessor, model binding +
|
|
8
|
+
invoke). Nothing in this package depends on any agent framework.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from typing import Any, Optional, TypedDict
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"A2UI_OPERATIONS_KEY",
|
|
19
|
+
"BASIC_CATALOG_ID",
|
|
20
|
+
"RENDER_A2UI_TOOL_DEF",
|
|
21
|
+
"DEFAULT_SURFACE_ID",
|
|
22
|
+
"GENERATE_A2UI_TOOL_NAME",
|
|
23
|
+
"GENERATE_A2UI_TOOL_DESCRIPTION",
|
|
24
|
+
"GENERATE_A2UI_ARG_DESCRIPTIONS",
|
|
25
|
+
"create_surface",
|
|
26
|
+
"update_components",
|
|
27
|
+
"update_data_model",
|
|
28
|
+
"build_context_prompt",
|
|
29
|
+
"find_prior_surface",
|
|
30
|
+
"build_subagent_prompt",
|
|
31
|
+
"assemble_ops",
|
|
32
|
+
"wrap_as_operations_envelope",
|
|
33
|
+
"wrap_error_envelope",
|
|
34
|
+
"prepare_a2ui_request",
|
|
35
|
+
"build_a2ui_envelope",
|
|
36
|
+
"PriorSurface",
|
|
37
|
+
"EditContext",
|
|
38
|
+
"PreparedA2UIRequest",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
A2UI_OPERATIONS_KEY = "a2ui_operations"
|
|
43
|
+
"""Container key the A2UI middleware looks for in tool results."""
|
|
44
|
+
|
|
45
|
+
BASIC_CATALOG_ID = "https://a2ui.org/specification/v0_9/basic_catalog.json"
|
|
46
|
+
"""Default catalog id used when the subagent does not specify one."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# Op builders
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def create_surface(surface_id: str, catalog_id: str) -> dict[str, Any]:
|
|
55
|
+
return {
|
|
56
|
+
"version": "v0.9",
|
|
57
|
+
"createSurface": {"surfaceId": surface_id, "catalogId": catalog_id},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def update_components(
|
|
62
|
+
surface_id: str, components: list[dict[str, Any]]
|
|
63
|
+
) -> dict[str, Any]:
|
|
64
|
+
return {
|
|
65
|
+
"version": "v0.9",
|
|
66
|
+
"updateComponents": {"surfaceId": surface_id, "components": components},
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def update_data_model(
|
|
71
|
+
surface_id: str, data: Any, path: str = "/"
|
|
72
|
+
) -> dict[str, Any]:
|
|
73
|
+
return {
|
|
74
|
+
"version": "v0.9",
|
|
75
|
+
"updateDataModel": {"surfaceId": surface_id, "path": path, "value": data},
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# Inner render_a2ui tool definition
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
RENDER_A2UI_TOOL_DEF: dict[str, Any] = {
|
|
84
|
+
"type": "function",
|
|
85
|
+
"function": {
|
|
86
|
+
"name": "render_a2ui",
|
|
87
|
+
"description": (
|
|
88
|
+
"Render a dynamic A2UI v0.9 surface. The root component must have "
|
|
89
|
+
"id 'root'. Use components from the available catalog only."
|
|
90
|
+
),
|
|
91
|
+
"parameters": {
|
|
92
|
+
"type": "object",
|
|
93
|
+
"properties": {
|
|
94
|
+
"surfaceId": {
|
|
95
|
+
"type": "string",
|
|
96
|
+
"description": "Unique surface identifier.",
|
|
97
|
+
},
|
|
98
|
+
"components": {
|
|
99
|
+
"type": "array",
|
|
100
|
+
"description": (
|
|
101
|
+
"A2UI v0.9 component array (flat format). The root "
|
|
102
|
+
"component must have id 'root'."
|
|
103
|
+
),
|
|
104
|
+
"items": {"type": "object"},
|
|
105
|
+
},
|
|
106
|
+
"data": {
|
|
107
|
+
"type": "object",
|
|
108
|
+
"description": (
|
|
109
|
+
"Optional initial data model for the surface (form "
|
|
110
|
+
"values, list items for data-bound components, etc.)."
|
|
111
|
+
),
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
"required": ["surfaceId", "components"],
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
"""JSON schema for the inner ``render_a2ui`` tool the subagent is forced to call."""
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
# State helpers
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def build_context_prompt(state: dict) -> str:
|
|
127
|
+
"""Assemble the prompt prefix from AG-UI state context entries + the A2UI
|
|
128
|
+
component catalog.
|
|
129
|
+
|
|
130
|
+
Framework integrations conventionally extract the catalog into
|
|
131
|
+
``state["ag-ui"]["a2ui_schema"]`` and forward other context entries
|
|
132
|
+
(generation guidelines, design guidelines) under
|
|
133
|
+
``state["ag-ui"]["context"]``.
|
|
134
|
+
"""
|
|
135
|
+
ag_ui = state.get("ag-ui", {}) or {}
|
|
136
|
+
parts: list[str] = []
|
|
137
|
+
|
|
138
|
+
for entry in ag_ui.get("context", []) or []:
|
|
139
|
+
if isinstance(entry, dict):
|
|
140
|
+
desc = entry.get("description")
|
|
141
|
+
value = entry.get("value")
|
|
142
|
+
else:
|
|
143
|
+
desc = getattr(entry, "description", None)
|
|
144
|
+
value = getattr(entry, "value", None)
|
|
145
|
+
# Mirror the TS toolkit: a null/None value with a description must NOT
|
|
146
|
+
# leak the literal string "None" into the subagent prompt. f-string
|
|
147
|
+
# interpolation would do that — coerce to "" first.
|
|
148
|
+
value_str = "" if value is None else str(value)
|
|
149
|
+
if desc:
|
|
150
|
+
parts.append(f"## {desc}\n{value_str}\n")
|
|
151
|
+
elif value_str:
|
|
152
|
+
parts.append(f"{value_str}\n")
|
|
153
|
+
|
|
154
|
+
a2ui_schema = ag_ui.get("a2ui_schema")
|
|
155
|
+
if a2ui_schema:
|
|
156
|
+
parts.append(f"## Available Components\n{a2ui_schema}\n")
|
|
157
|
+
|
|
158
|
+
return "\n".join(parts)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
# Prior surface lookup (used for intent="update")
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class PriorSurface(TypedDict, total=False):
|
|
167
|
+
components: list[dict[str, Any]]
|
|
168
|
+
data: Any
|
|
169
|
+
catalogId: Optional[str]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _message_role_and_content(msg: Any) -> tuple[Optional[str], Any]:
|
|
173
|
+
"""Read a message's role/type and content from either an object or a dict.
|
|
174
|
+
|
|
175
|
+
LangChain ToolMessage instances expose ``.type``/``.role``/``.content`` as
|
|
176
|
+
attributes; messages that round-tripped through JSON arrive as plain dicts.
|
|
177
|
+
Either shape needs to work — the prior-surface walker must not silently skip
|
|
178
|
+
dict-shaped history.
|
|
179
|
+
"""
|
|
180
|
+
if isinstance(msg, dict):
|
|
181
|
+
role = msg.get("type") or msg.get("role")
|
|
182
|
+
return role, msg.get("content")
|
|
183
|
+
return (
|
|
184
|
+
getattr(msg, "type", None) or getattr(msg, "role", None),
|
|
185
|
+
getattr(msg, "content", None),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def find_prior_surface(
|
|
190
|
+
messages: list[Any], surface_id: str
|
|
191
|
+
) -> Optional[PriorSurface]:
|
|
192
|
+
"""Locate the most recent rendered state for ``surface_id`` in message history.
|
|
193
|
+
|
|
194
|
+
Walks backwards over tool messages whose content is a JSON string containing
|
|
195
|
+
``a2ui_operations`` for the given surface, accumulating the most recent
|
|
196
|
+
value of each field (``components``, ``data``, ``catalogId``) across the
|
|
197
|
+
walk. A late-turn message that only emits ``updateDataModel`` no longer
|
|
198
|
+
blanks the components / catalogId established by an earlier turn — the
|
|
199
|
+
function returns the surface's *latest known state*, not just what the most
|
|
200
|
+
recent matching message happened to carry.
|
|
201
|
+
|
|
202
|
+
Accepts both object-shaped and dict-shaped messages.
|
|
203
|
+
|
|
204
|
+
Returns the reconstructed ``{"components": [...], "data": ..., "catalogId": ...}``
|
|
205
|
+
or ``None`` if no matching surface is found anywhere in history.
|
|
206
|
+
"""
|
|
207
|
+
# Per-message end-state is computed FORWARD because the renderer applies
|
|
208
|
+
# ops in document order. The last op affecting the surface in a message
|
|
209
|
+
# determines that message's contribution — including ``deleteSurface``,
|
|
210
|
+
# which wipes the surface. If the NEWEST message to mention the surface
|
|
211
|
+
# ends in delete, return ``None``: older create/update ops are stale and
|
|
212
|
+
# would resurrect a surface the renderer no longer shows.
|
|
213
|
+
components: Optional[list[dict[str, Any]]] = None
|
|
214
|
+
data: Any = None
|
|
215
|
+
data_seen = False
|
|
216
|
+
catalog_id: Optional[str] = None
|
|
217
|
+
matched = False
|
|
218
|
+
|
|
219
|
+
for msg in reversed(messages):
|
|
220
|
+
role, content = _message_role_and_content(msg)
|
|
221
|
+
if role not in ("tool", "ToolMessage"):
|
|
222
|
+
continue
|
|
223
|
+
if not isinstance(content, str):
|
|
224
|
+
continue
|
|
225
|
+
try:
|
|
226
|
+
parsed = json.loads(content)
|
|
227
|
+
except (ValueError, TypeError):
|
|
228
|
+
continue
|
|
229
|
+
if not isinstance(parsed, dict):
|
|
230
|
+
continue
|
|
231
|
+
ops = parsed.get(A2UI_OPERATIONS_KEY)
|
|
232
|
+
if not isinstance(ops, list):
|
|
233
|
+
continue
|
|
234
|
+
|
|
235
|
+
# Compute this message's end state for surface_id by walking ops
|
|
236
|
+
# forward. ``deleteSurface`` resets the per-message accumulator;
|
|
237
|
+
# subsequent create / update ops in the same message restore it.
|
|
238
|
+
msg_mentions = False
|
|
239
|
+
msg_deleted = False
|
|
240
|
+
msg_catalog_id: Optional[str] = None
|
|
241
|
+
msg_components: Optional[list[dict[str, Any]]] = None
|
|
242
|
+
msg_data: Any = None
|
|
243
|
+
msg_data_seen = False
|
|
244
|
+
|
|
245
|
+
for op in ops:
|
|
246
|
+
if not isinstance(op, dict):
|
|
247
|
+
continue
|
|
248
|
+
if "deleteSurface" in op:
|
|
249
|
+
ds = op["deleteSurface"]
|
|
250
|
+
if isinstance(ds, dict) and ds.get("surfaceId") == surface_id:
|
|
251
|
+
msg_mentions = True
|
|
252
|
+
msg_deleted = True
|
|
253
|
+
msg_catalog_id = None
|
|
254
|
+
msg_components = None
|
|
255
|
+
msg_data = None
|
|
256
|
+
msg_data_seen = False
|
|
257
|
+
continue
|
|
258
|
+
if "createSurface" in op:
|
|
259
|
+
cs = op["createSurface"]
|
|
260
|
+
if isinstance(cs, dict) and cs.get("surfaceId") == surface_id:
|
|
261
|
+
msg_mentions = True
|
|
262
|
+
msg_deleted = False
|
|
263
|
+
if isinstance(cs.get("catalogId"), str):
|
|
264
|
+
msg_catalog_id = cs["catalogId"]
|
|
265
|
+
if "updateComponents" in op:
|
|
266
|
+
uc = op["updateComponents"]
|
|
267
|
+
if isinstance(uc, dict) and uc.get("surfaceId") == surface_id:
|
|
268
|
+
msg_mentions = True
|
|
269
|
+
msg_deleted = False
|
|
270
|
+
if isinstance(uc.get("components"), list):
|
|
271
|
+
msg_components = uc["components"]
|
|
272
|
+
if "updateDataModel" in op:
|
|
273
|
+
ud = op["updateDataModel"]
|
|
274
|
+
if isinstance(ud, dict) and ud.get("surfaceId") == surface_id:
|
|
275
|
+
msg_mentions = True
|
|
276
|
+
msg_deleted = False
|
|
277
|
+
msg_data = ud.get("value")
|
|
278
|
+
msg_data_seen = True
|
|
279
|
+
|
|
280
|
+
if not msg_mentions:
|
|
281
|
+
continue
|
|
282
|
+
|
|
283
|
+
if not matched:
|
|
284
|
+
# Newest message that mentions the surface — its end state is
|
|
285
|
+
# authoritative.
|
|
286
|
+
if msg_deleted:
|
|
287
|
+
return None
|
|
288
|
+
matched = True
|
|
289
|
+
catalog_id = msg_catalog_id
|
|
290
|
+
components = msg_components
|
|
291
|
+
data = msg_data
|
|
292
|
+
data_seen = msg_data_seen
|
|
293
|
+
else:
|
|
294
|
+
# Older message: fill in only the fields not yet set. A delete
|
|
295
|
+
# here is overridden by the newer state already recorded.
|
|
296
|
+
if msg_deleted:
|
|
297
|
+
continue
|
|
298
|
+
if catalog_id is None and msg_catalog_id is not None:
|
|
299
|
+
catalog_id = msg_catalog_id
|
|
300
|
+
if components is None and msg_components is not None:
|
|
301
|
+
components = msg_components
|
|
302
|
+
if not data_seen and msg_data_seen:
|
|
303
|
+
data = msg_data
|
|
304
|
+
data_seen = True
|
|
305
|
+
|
|
306
|
+
# Early-exit once every field is populated — nothing older can override.
|
|
307
|
+
if matched and components is not None and catalog_id is not None and data_seen:
|
|
308
|
+
return {"components": components, "data": data, "catalogId": catalog_id}
|
|
309
|
+
|
|
310
|
+
if not matched:
|
|
311
|
+
return None
|
|
312
|
+
return {
|
|
313
|
+
"components": components or [],
|
|
314
|
+
"data": data,
|
|
315
|
+
"catalogId": catalog_id,
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
# ---------------------------------------------------------------------------
|
|
320
|
+
# Prompt assembly
|
|
321
|
+
# ---------------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
class EditContext(TypedDict, total=False):
|
|
325
|
+
surfaceId: str
|
|
326
|
+
prior: PriorSurface
|
|
327
|
+
changes: Optional[str]
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def build_subagent_prompt(
|
|
331
|
+
*,
|
|
332
|
+
context_prompt: str,
|
|
333
|
+
composition_guide: Optional[str] = None,
|
|
334
|
+
edit_context: Optional[EditContext] = None,
|
|
335
|
+
) -> str:
|
|
336
|
+
"""Compose the full subagent system prompt.
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
context_prompt: Output of ``build_context_prompt(state)``.
|
|
340
|
+
composition_guide: Project-specific composition rules to append.
|
|
341
|
+
edit_context: When set, instructs the subagent to edit a prior surface
|
|
342
|
+
in place (used by ``intent="update"``).
|
|
343
|
+
"""
|
|
344
|
+
parts: list[str] = []
|
|
345
|
+
if context_prompt:
|
|
346
|
+
parts.append(context_prompt)
|
|
347
|
+
if composition_guide:
|
|
348
|
+
parts.append(composition_guide)
|
|
349
|
+
|
|
350
|
+
if edit_context:
|
|
351
|
+
surface_id = edit_context.get("surfaceId")
|
|
352
|
+
prior = edit_context.get("prior") or {}
|
|
353
|
+
changes = edit_context.get("changes")
|
|
354
|
+
edit_block = (
|
|
355
|
+
"## Editing an existing surface\n"
|
|
356
|
+
f"You are editing surface '{surface_id}'. Produce the FULL "
|
|
357
|
+
f"updated components array and data model — not just a diff. "
|
|
358
|
+
f"Preserve component ids that the user has not asked to change so "
|
|
359
|
+
f"the renderer can reconcile them. Reuse the same catalogId.\n\n"
|
|
360
|
+
f"### Previous components\n"
|
|
361
|
+
f"{json.dumps(prior.get('components', []), indent=2)}\n\n"
|
|
362
|
+
f"### Previous data\n"
|
|
363
|
+
f"{json.dumps(prior.get('data'), indent=2)}\n"
|
|
364
|
+
)
|
|
365
|
+
if changes:
|
|
366
|
+
edit_block += f"\n### Requested changes\n{changes}\n"
|
|
367
|
+
parts.append(edit_block)
|
|
368
|
+
|
|
369
|
+
return "\n".join(p for p in parts if p)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
# ---------------------------------------------------------------------------
|
|
373
|
+
# Operations envelope
|
|
374
|
+
# ---------------------------------------------------------------------------
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def assemble_ops(
|
|
378
|
+
*,
|
|
379
|
+
intent: str,
|
|
380
|
+
surface_id: str,
|
|
381
|
+
catalog_id: str,
|
|
382
|
+
components: list[dict[str, Any]],
|
|
383
|
+
data: Optional[dict[str, Any]] = None,
|
|
384
|
+
) -> list[dict[str, Any]]:
|
|
385
|
+
"""Produce the final A2UI v0.9 operation list for a render result.
|
|
386
|
+
|
|
387
|
+
``intent="create"`` emits ``[createSurface, updateComponents, updateDataModel?]``.
|
|
388
|
+
Any other intent (e.g. ``"update"``) skips ``createSurface`` so the
|
|
389
|
+
frontend reconciles the existing surface in place rather than erroring
|
|
390
|
+
(per v0.9 spec, ``createSurface`` on an existing id is invalid).
|
|
391
|
+
"""
|
|
392
|
+
ops: list[dict[str, Any]] = []
|
|
393
|
+
if intent != "update":
|
|
394
|
+
ops.append(create_surface(surface_id, catalog_id))
|
|
395
|
+
ops.append(update_components(surface_id, components))
|
|
396
|
+
if data:
|
|
397
|
+
ops.append(update_data_model(surface_id, data))
|
|
398
|
+
return ops
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def wrap_as_operations_envelope(ops: list[dict[str, Any]]) -> str:
|
|
402
|
+
"""Wrap a list of A2UI operations as the JSON envelope the A2UI middleware
|
|
403
|
+
looks for in tool results."""
|
|
404
|
+
return json.dumps({A2UI_OPERATIONS_KEY: ops})
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def wrap_error_envelope(message: str) -> str:
|
|
408
|
+
"""Wrap an error as the JSON string a subagent tool returns when it can't
|
|
409
|
+
produce a surface. Keeps the error shape consistent across frameworks."""
|
|
410
|
+
return json.dumps({"error": message})
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
# ---------------------------------------------------------------------------
|
|
414
|
+
# Subagent-tool defaults (shared so every framework adapter advertises the
|
|
415
|
+
# same planner-facing surface and behaviour)
|
|
416
|
+
# ---------------------------------------------------------------------------
|
|
417
|
+
|
|
418
|
+
DEFAULT_SURFACE_ID = "dynamic-surface"
|
|
419
|
+
"""Surface id used when the subagent omits ``surfaceId`` on a create."""
|
|
420
|
+
|
|
421
|
+
GENERATE_A2UI_TOOL_NAME = "generate_a2ui"
|
|
422
|
+
"""Default name the outer A2UI tool is advertised under to the main planner."""
|
|
423
|
+
|
|
424
|
+
GENERATE_A2UI_TOOL_DESCRIPTION = (
|
|
425
|
+
"Generate or update a dynamic A2UI surface based on the conversation. "
|
|
426
|
+
"A secondary LLM designs the UI components and data. "
|
|
427
|
+
"Use intent='create' (default) when the user requests new visual content "
|
|
428
|
+
"(cards, forms, lists, dashboards, comparisons, etc.). "
|
|
429
|
+
"Use intent='update' with target_surface_id to modify a surface you "
|
|
430
|
+
"previously rendered (e.g. 'change the second card's price', "
|
|
431
|
+
"'add a Buy button', 'use red instead of blue')."
|
|
432
|
+
)
|
|
433
|
+
"""Default description shown to the main agent's planner."""
|
|
434
|
+
|
|
435
|
+
GENERATE_A2UI_ARG_DESCRIPTIONS: dict[str, str] = {
|
|
436
|
+
"intent": (
|
|
437
|
+
"'create' to render a new surface; 'update' to modify a surface "
|
|
438
|
+
"previously rendered in this conversation. Defaults to 'create'."
|
|
439
|
+
),
|
|
440
|
+
"target_surface_id": (
|
|
441
|
+
"Required when intent='update'. The surface id of the prior render to modify."
|
|
442
|
+
),
|
|
443
|
+
"changes": (
|
|
444
|
+
"Optional natural-language description of the changes to apply when intent='update'."
|
|
445
|
+
),
|
|
446
|
+
}
|
|
447
|
+
"""Planner-facing descriptions for the outer tool's three arguments."""
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
# ---------------------------------------------------------------------------
|
|
451
|
+
# High-level orchestration
|
|
452
|
+
#
|
|
453
|
+
# These two functions hold the entire create/update decision + prompt prep +
|
|
454
|
+
# result-assembly logic so every framework adapter is reduced to pure glue
|
|
455
|
+
# (tool decorator, state access, model bind+invoke, tool-call read).
|
|
456
|
+
# ---------------------------------------------------------------------------
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
class PreparedA2UIRequest(TypedDict, total=False):
|
|
460
|
+
prompt: str
|
|
461
|
+
is_update: bool
|
|
462
|
+
prior: Optional[PriorSurface]
|
|
463
|
+
error: Optional[str]
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def prepare_a2ui_request(
|
|
467
|
+
*,
|
|
468
|
+
intent: Optional[str],
|
|
469
|
+
target_surface_id: Optional[str],
|
|
470
|
+
changes: Optional[str],
|
|
471
|
+
messages: list[Any],
|
|
472
|
+
state: dict,
|
|
473
|
+
composition_guide: Optional[str] = None,
|
|
474
|
+
) -> PreparedA2UIRequest:
|
|
475
|
+
"""Resolve the create/update decision, locate any prior surface, and build
|
|
476
|
+
the subagent system prompt.
|
|
477
|
+
|
|
478
|
+
Returns a dict with ``error`` set (and no ``prompt``) when the request is
|
|
479
|
+
invalid — an ``update`` referencing a surface not found in history.
|
|
480
|
+
"""
|
|
481
|
+
resolved_intent = intent or "create"
|
|
482
|
+
is_update = resolved_intent == "update" and bool(target_surface_id)
|
|
483
|
+
|
|
484
|
+
# is_update being True already narrows target_surface_id to non-empty str;
|
|
485
|
+
# assert it explicitly so a type checker sees the same narrowing the runtime
|
|
486
|
+
# condition guarantees, without resorting to a blanket type: ignore.
|
|
487
|
+
if is_update:
|
|
488
|
+
assert target_surface_id is not None
|
|
489
|
+
prior = find_prior_surface(messages, target_surface_id)
|
|
490
|
+
else:
|
|
491
|
+
prior = None
|
|
492
|
+
|
|
493
|
+
if is_update and prior is None:
|
|
494
|
+
# Match TS shape: omit ``prior`` from the error branch so presence
|
|
495
|
+
# checks like ``"prior" in prep`` distinguish success from failure.
|
|
496
|
+
return {
|
|
497
|
+
"prompt": "",
|
|
498
|
+
"is_update": is_update,
|
|
499
|
+
"error": (
|
|
500
|
+
f"intent='update' requested target_surface_id="
|
|
501
|
+
f"'{target_surface_id}' but no prior render of that surface "
|
|
502
|
+
f"was found in conversation history"
|
|
503
|
+
),
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
prompt = build_subagent_prompt(
|
|
507
|
+
context_prompt=build_context_prompt(state),
|
|
508
|
+
composition_guide=composition_guide,
|
|
509
|
+
edit_context=(
|
|
510
|
+
{"surfaceId": target_surface_id, "prior": prior, "changes": changes}
|
|
511
|
+
if prior is not None
|
|
512
|
+
else None
|
|
513
|
+
),
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
# Omit ``error`` on success so ``"error" in prep`` is a meaningful presence
|
|
517
|
+
# check (matches the TS counterpart which only returns the key on failure).
|
|
518
|
+
return {"prompt": prompt, "is_update": is_update, "prior": prior}
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def build_a2ui_envelope(
|
|
522
|
+
*,
|
|
523
|
+
args: dict[str, Any],
|
|
524
|
+
is_update: bool,
|
|
525
|
+
target_surface_id: Optional[str],
|
|
526
|
+
prior: Optional[PriorSurface],
|
|
527
|
+
default_surface_id: str = DEFAULT_SURFACE_ID,
|
|
528
|
+
default_catalog_id: str = BASIC_CATALOG_ID,
|
|
529
|
+
) -> str:
|
|
530
|
+
"""Turn the subagent's structured output into the final operations envelope.
|
|
531
|
+
|
|
532
|
+
Catalog ownership stays with the host: the subagent never picks a catalog,
|
|
533
|
+
so the id comes from the prior surface (update) or the configured default
|
|
534
|
+
(create) — never from the model's args.
|
|
535
|
+
"""
|
|
536
|
+
# Treat empty-string defaults as unset (mirror the TS guard). Without this,
|
|
537
|
+
# a misconfigured host passing ``""`` for default_surface_id /
|
|
538
|
+
# default_catalog_id would propagate the empty string into the emitted ops
|
|
539
|
+
# and surface as "Catalog not found: " / blank surface ids at render time,
|
|
540
|
+
# hiding the real cause.
|
|
541
|
+
safe_default_surface_id = default_surface_id or DEFAULT_SURFACE_ID
|
|
542
|
+
safe_default_catalog_id = default_catalog_id or BASIC_CATALOG_ID
|
|
543
|
+
|
|
544
|
+
# Narrow args["surfaceId"] to a non-empty STRING — the model is untrusted
|
|
545
|
+
# and may return ``null``, a number, a list, or an empty string. Without
|
|
546
|
+
# this, those values propagate into ``createSurface.surfaceId`` and the
|
|
547
|
+
# renderer either crashes or silently mounts to an unreachable surface
|
|
548
|
+
# id. Mirrors the TS narrow (``typeof === "string" && length > 0``).
|
|
549
|
+
raw_arg_surface_id = args.get("surfaceId")
|
|
550
|
+
arg_surface_id = (
|
|
551
|
+
raw_arg_surface_id
|
|
552
|
+
if isinstance(raw_arg_surface_id, str) and len(raw_arg_surface_id) > 0
|
|
553
|
+
else ""
|
|
554
|
+
)
|
|
555
|
+
if is_update:
|
|
556
|
+
surface_id = target_surface_id or safe_default_surface_id
|
|
557
|
+
else:
|
|
558
|
+
surface_id = arg_surface_id or safe_default_surface_id
|
|
559
|
+
catalog_id = (prior or {}).get("catalogId") or safe_default_catalog_id
|
|
560
|
+
# Narrow to the documented shapes — the model's args are untrusted.
|
|
561
|
+
raw_components = args.get("components")
|
|
562
|
+
components = raw_components if isinstance(raw_components, list) else []
|
|
563
|
+
raw_data = args.get("data")
|
|
564
|
+
data = raw_data if isinstance(raw_data, dict) else {}
|
|
565
|
+
|
|
566
|
+
ops = assemble_ops(
|
|
567
|
+
intent="update" if is_update else "create",
|
|
568
|
+
surface_id=surface_id,
|
|
569
|
+
catalog_id=catalog_id,
|
|
570
|
+
components=components,
|
|
571
|
+
data=data,
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
return wrap_as_operations_envelope(ops)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ag-ui-a2ui-toolkit"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "Framework-agnostic helpers for building A2UI subagent tools — op builders, prompt assembly, history walkers, and request/envelope orchestration shared across framework adapters."
|
|
5
|
+
authors = [
|
|
6
|
+
{ name = "Ran Shem Tov", email = "ran@copilotkit.ai" }
|
|
7
|
+
]
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
license = "MIT"
|
|
10
|
+
requires-python = ">=3.10,<3.15"
|
|
11
|
+
dependencies = []
|
|
12
|
+
|
|
13
|
+
[build-system]
|
|
14
|
+
requires = ["uv_build>=0.8.0,<0.9"]
|
|
15
|
+
build-backend = "uv_build"
|
|
16
|
+
|
|
17
|
+
[tool.uv.build-backend]
|
|
18
|
+
module-root = ""
|
|
19
|
+
module-name = "ag_ui_a2ui_toolkit"
|
|
20
|
+
|
|
21
|
+
[tool.ag-ui.scripts]
|
|
22
|
+
test = "python -m unittest discover tests"
|