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"