keystone-agui 0.1.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.
Files changed (70) hide show
  1. keystone_agui-0.1.1/PKG-INFO +12 -0
  2. keystone_agui-0.1.1/__init__.py +127 -0
  3. keystone_agui-0.1.1/a2a_ext.py +522 -0
  4. keystone_agui-0.1.1/a2ui.py +242 -0
  5. keystone_agui-0.1.1/actions.py +57 -0
  6. keystone_agui-0.1.1/bindings.py +350 -0
  7. keystone_agui-0.1.1/blocks.py +77 -0
  8. keystone_agui-0.1.1/catalog_check.py +469 -0
  9. keystone_agui-0.1.1/chat_surfaces.py +196 -0
  10. keystone_agui-0.1.1/denial.py +363 -0
  11. keystone_agui-0.1.1/file_surfaces.py +207 -0
  12. keystone_agui-0.1.1/gate.py +156 -0
  13. keystone_agui-0.1.1/generative/__init__.py +20 -0
  14. keystone_agui-0.1.1/generative/catalog.py +361 -0
  15. keystone_agui-0.1.1/generative/channel.py +41 -0
  16. keystone_agui-0.1.1/generative/display_only.py +135 -0
  17. keystone_agui-0.1.1/generative/frames.py +37 -0
  18. keystone_agui-0.1.1/generative/grounding.py +182 -0
  19. keystone_agui-0.1.1/generative/history.py +116 -0
  20. keystone_agui-0.1.1/generative/patch.py +358 -0
  21. keystone_agui-0.1.1/generative/pies.py +92 -0
  22. keystone_agui-0.1.1/generative/prepare.py +140 -0
  23. keystone_agui-0.1.1/generative/refine.py +59 -0
  24. keystone_agui-0.1.1/generative/reply.py +156 -0
  25. keystone_agui-0.1.1/generative/stream.py +121 -0
  26. keystone_agui-0.1.1/generative/tools.py +227 -0
  27. keystone_agui-0.1.1/generative/tree.py +625 -0
  28. keystone_agui-0.1.1/generative/units.py +259 -0
  29. keystone_agui-0.1.1/inbound.py +671 -0
  30. keystone_agui-0.1.1/keystone_agui.egg-info/PKG-INFO +12 -0
  31. keystone_agui-0.1.1/keystone_agui.egg-info/SOURCES.txt +89 -0
  32. keystone_agui-0.1.1/keystone_agui.egg-info/dependency_links.txt +1 -0
  33. keystone_agui-0.1.1/keystone_agui.egg-info/requires.txt +8 -0
  34. keystone_agui-0.1.1/keystone_agui.egg-info/top_level.txt +1 -0
  35. keystone_agui-0.1.1/native_card.py +158 -0
  36. keystone_agui-0.1.1/outbound.py +48 -0
  37. keystone_agui-0.1.1/pyproject.toml +54 -0
  38. keystone_agui-0.1.1/schemas/basic_catalog.json +1383 -0
  39. keystone_agui-0.1.1/schemas/common_types.json +305 -0
  40. keystone_agui-0.1.1/schemas/keystone_catalog.json +2113 -0
  41. keystone_agui-0.1.1/schemas/keystone_components.json +371 -0
  42. keystone_agui-0.1.1/setup.cfg +4 -0
  43. keystone_agui-0.1.1/sign_in.py +368 -0
  44. keystone_agui-0.1.1/surfaces.py +134 -0
  45. keystone_agui-0.1.1/tests/test_a2a_ext.py +322 -0
  46. keystone_agui-0.1.1/tests/test_a2a_files.py +68 -0
  47. keystone_agui-0.1.1/tests/test_a2ui.py +120 -0
  48. keystone_agui-0.1.1/tests/test_actions.py +30 -0
  49. keystone_agui-0.1.1/tests/test_bindings.py +376 -0
  50. keystone_agui-0.1.1/tests/test_blocks.py +54 -0
  51. keystone_agui-0.1.1/tests/test_catalog_check.py +259 -0
  52. keystone_agui-0.1.1/tests/test_catalog_check_multi.py +70 -0
  53. keystone_agui-0.1.1/tests/test_chat_surfaces.py +133 -0
  54. keystone_agui-0.1.1/tests/test_denial.py +595 -0
  55. keystone_agui-0.1.1/tests/test_file_surfaces.py +85 -0
  56. keystone_agui-0.1.1/tests/test_gate.py +122 -0
  57. keystone_agui-0.1.1/tests/test_generative_catalog.py +89 -0
  58. keystone_agui-0.1.1/tests/test_generative_golden.py +138 -0
  59. keystone_agui-0.1.1/tests/test_generative_lib.py +257 -0
  60. keystone_agui-0.1.1/tests/test_generative_patch.py +492 -0
  61. keystone_agui-0.1.1/tests/test_generative_tree.py +282 -0
  62. keystone_agui-0.1.1/tests/test_inbound.py +684 -0
  63. keystone_agui-0.1.1/tests/test_keystone_catalog.py +131 -0
  64. keystone_agui-0.1.1/tests/test_keystone_components.py +131 -0
  65. keystone_agui-0.1.1/tests/test_sign_in.py +312 -0
  66. keystone_agui-0.1.1/tests/test_surface_tree.py +138 -0
  67. keystone_agui-0.1.1/tests/test_surfaces.py +120 -0
  68. keystone_agui-0.1.1/tests/test_units.py +174 -0
  69. keystone_agui-0.1.1/tests/test_unknown_components_fdp6967.py +46 -0
  70. keystone_agui-0.1.1/units.py +167 -0
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: keystone-agui
3
+ Version: 0.1.1
4
+ Summary: AG-UI (Agent-User Interaction Protocol) shared surface: reading RunAgentInput (and what is deliberately refused from it) + encoding events for SSE. Consumed by every service that speaks AG-UI outward, so the request contract cannot drift between them.
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: ag-ui-protocol<1,>=0.1.19
7
+ Provides-Extra: dev
8
+ Requires-Dist: jsonschema<5,>=4.18; extra == "dev"
9
+ Requires-Dist: referencing<1,>=0.30; extra == "dev"
10
+ Requires-Dist: pytest<9,>=8; extra == "dev"
11
+ Requires-Dist: pytest-cov<7,>=6; extra == "dev"
12
+ Requires-Dist: ruff==0.15.15; extra == "dev"
@@ -0,0 +1,127 @@
1
+ """AG-UI (Agent-User Interaction Protocol) — the shared surface every Keystone door speaks.
2
+
3
+ Two halves, and the split is the useful part:
4
+
5
+ * :mod:`keystone.agui.inbound` — reading ``RunAgentInput``, and what is deliberately REFUSED
6
+ from it. Shared because the refusals are security-relevant and must not differ per service.
7
+ * :mod:`keystone.agui.outbound` — encoding events onto the wire. Shared because the encoding
8
+ is the contract: camelCase aliases and SCREAMING_SNAKE ``type`` values come from the spec's
9
+ own models, and three hand-rolled copies is three chances to disagree with it.
10
+
11
+ And one companion protocol: :mod:`keystone.agui.a2ui` holds A2UI's three message primitives, the
12
+ component catalog every Keystone surface declares, and that catalog's icon enum — the parts that
13
+ are the same for everyone.
14
+
15
+ What is NOT here: the mapping from a service's internal vocabulary to AG-UI events, or to a UI.
16
+ That is each service's own business — ``visual-builder`` knows which of its trace subtypes are
17
+ middleware, ``master-agent`` knows what its graph steps mean and what a consult should look like,
18
+ and neither should learn the other's. This module briefly held master-agent's delegation UI (its
19
+ tool names, its wording, its icons) and that was the coupling this lib exists to remove, not to
20
+ host: every importer would have inherited one service's vocabulary.
21
+ """
22
+
23
+ from keystone.agui.a2ui import (
24
+ ACTIVITY_A2UI,
25
+ ACTIVITY_ADAPTIVE_CARD,
26
+ ACTIVITY_ADAPTIVE_CARD_KEY,
27
+ ACTIVITY_CARDS_KEY,
28
+ ACTIVITY_CARDS_V2,
29
+ ACTIVITY_COMPONENT_KEY,
30
+ ACTIVITY_COMPONENT_REGISTRY_KEY,
31
+ ACTIVITY_DRAWN_BY,
32
+ BUTTON_VARIANTS,
33
+ CATALOG_ICONS,
34
+ CATALOG_ID,
35
+ STEP_UI_KEY,
36
+ VERSION,
37
+ button_variant,
38
+ create_surface,
39
+ icon,
40
+ surface,
41
+ update_components,
42
+ update_data_model,
43
+ )
44
+ from keystone.agui.bindings import (
45
+ DISPLAY_COMPONENTS,
46
+ INPUT_COMPONENTS,
47
+ ChoiceBinding,
48
+ choice_values,
49
+ data_bindings,
50
+ display_only_paths,
51
+ template_of,
52
+ template_subtree_ids,
53
+ )
54
+ from keystone.agui.catalog_check import (
55
+ BASIC_CATALOG_ID,
56
+ KEYSTONE_CATALOG_ID,
57
+ component_names,
58
+ component_problems,
59
+ component_schemas,
60
+ surface_problems,
61
+ )
62
+ from keystone.agui.gate import GatedCall, gate_message, proposed_calls, resume_schema
63
+ from keystone.agui.inbound import (
64
+ A2UI_ACTION_PROP,
65
+ APPROVALS_PROP,
66
+ ARTIFACT_URI_SCHEME,
67
+ ESCAPE_PROP,
68
+ KEYSTONE_PROPS,
69
+ TurnRequest,
70
+ read_turn,
71
+ resume_entries,
72
+ resume_entry,
73
+ user_message_content,
74
+ )
75
+ from keystone.agui.outbound import encode, json_str
76
+
77
+ __all__ = [
78
+ "A2UI_ACTION_PROP",
79
+ "APPROVALS_PROP",
80
+ "ARTIFACT_URI_SCHEME",
81
+ "ACTIVITY_A2UI",
82
+ "ACTIVITY_ADAPTIVE_CARD",
83
+ "ACTIVITY_ADAPTIVE_CARD_KEY",
84
+ "ACTIVITY_DRAWN_BY",
85
+ "ACTIVITY_CARDS_V2",
86
+ "ACTIVITY_CARDS_KEY",
87
+ "ACTIVITY_COMPONENT_KEY",
88
+ "ACTIVITY_COMPONENT_REGISTRY_KEY",
89
+ "BASIC_CATALOG_ID",
90
+ "BUTTON_VARIANTS",
91
+ "CATALOG_ICONS",
92
+ "CATALOG_ID",
93
+ "DISPLAY_COMPONENTS",
94
+ "ESCAPE_PROP",
95
+ "INPUT_COMPONENTS",
96
+ "KEYSTONE_CATALOG_ID",
97
+ "KEYSTONE_PROPS",
98
+ "STEP_UI_KEY",
99
+ "VERSION",
100
+ "TurnRequest",
101
+ "button_variant",
102
+ "component_names",
103
+ "component_schemas",
104
+ "component_problems",
105
+ "create_surface",
106
+ "GatedCall",
107
+ "ChoiceBinding",
108
+ "choice_values",
109
+ "data_bindings",
110
+ "display_only_paths",
111
+ "encode",
112
+ "gate_message",
113
+ "json_str",
114
+ "proposed_calls",
115
+ "resume_schema",
116
+ "icon",
117
+ "read_turn",
118
+ "resume_entries",
119
+ "resume_entry",
120
+ "surface_problems",
121
+ "surface",
122
+ "user_message_content",
123
+ "template_of",
124
+ "template_subtree_ids",
125
+ "update_components",
126
+ "update_data_model",
127
+ ]
@@ -0,0 +1,522 @@
1
+ """A2UI over A2A — the a2ui.org extension's wire shapes (v0.9.1), as pure dict helpers.
2
+
3
+ One owner for both directions and both services: visual-builder EMITS these parts on its
4
+ A2A door and READS the click coming back; master-agent reads the parts off the a2a-sdk's
5
+ proto objects and emits the click. Two hand-rolled copies of `{"kind": "data", ...}` is
6
+ two chances to disagree about where the mime marker sits.
7
+
8
+ Detection is deliberately belt-and-braces: the spec marks the part with
9
+ ``metadata.mimeType``, but the a2a-sdk maps a JSON part onto a protobuf whose ``data``
10
+ field is a bare ``Value`` — part-level metadata does not reliably survive the mapping.
11
+ So a reader accepts EITHER the marker or the structure (every A2UI message carries one
12
+ of a closed set of top-level keys), and an emitter stamps the marker on the artifact's
13
+ metadata too, which does survive.
14
+
15
+ **The message list travels wrapped in an object, under ``STEP_UI_KEY`` — a deliberate
16
+ deviation from the a2ui.org spec's own literal example, which draws ``data`` as a bare
17
+ list.** Found the hard way, building master-agent's A2A client (Task 5 of FDP-3028): a
18
+ bare list crossing ``DataPart.data`` fails Pydantic validation inside the a2a-sdk BEFORE
19
+ any of this repo's code runs. Confirmed against ``a2a-sdk`` 1.1.2 — the newest release
20
+ ever published, so there is no newer version to pick up a fix —
21
+ ``a2a.compat.v0_3.types.DataPart.data`` is typed ``dict[str, Any]``, not ``list``.
22
+ Feeding the spec-literal shape through a real client reproducibly raises
23
+ ``pydantic_core.ValidationError: Input should be a valid dictionary`` on both the
24
+ streaming and the buffered path, which the caller cannot tell apart from a genuine
25
+ outage. Three reasons this is the fix rather than a client-side workaround:
26
+
27
+ 1. A2A's ``DataPart.data`` is a structured object BY CONTRACT, and the transport
28
+ enforces it. When the extension spec and the transport contract disagree, the
29
+ transport wins — nothing crosses otherwise.
30
+ 2. This is the mechanism this stack already runs on: the shipped HITL resume part wraps
31
+ ITS list in a dict too (``{"hitl_decisions": [...]}``,
32
+ ``master-agent/clients/specialist_client.py``) and has worked in production against
33
+ real Visual Builder.
34
+ 3. ``STEP_UI_KEY`` ("a2ui") is not an invented name — it is this repo's EXISTING key for
35
+ exactly this payload (:mod:`keystone.agui.a2ui`, the same key VB's own runtime ``ui``
36
+ frame already uses for the same message list).
37
+
38
+ A future reader must not "fix" :func:`a2ui_data_part` back to the spec's literal bare
39
+ list — that is the shape that cannot cross the wire at all, not a stricter-than-needed
40
+ choice made here. The READ side stays liberal on purpose: :func:`a2ui_messages_of_part`
41
+ accepts both this wrapped shape and the spec-literal bare list, because a peer that
42
+ reaches us over a transport that does not type-check ``DataPart.data`` (or an older
43
+ build of this repo) may still send the bare form, and it must keep working.
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ from typing import Any
49
+
50
+ from .a2ui import STEP_UI_KEY, VERSION
51
+
52
+ EXTENSION_URI = "https://a2ui.org/a2a-extension/a2ui/v0.9.1"
53
+ MIME_TYPE = "application/a2ui+json"
54
+ ACTIVATION_HEADER = "X-A2A-Extensions"
55
+
56
+ #: The closed set of A2UI message kinds — one of these is present on every message the
57
+ #: v0.9 protocol defines, which is what makes structural detection sound.
58
+ _MESSAGE_KEYS = frozenset({"createSurface", "updateComponents", "updateDataModel", "deleteSurface", "action"})
59
+
60
+
61
+ def a2ui_part_data(messages: list[dict[str, Any]]) -> dict[str, Any]:
62
+ """The dict payload of an A2UI DataPart — the wrapper R9 requires, in one place.
63
+
64
+ Both directions build a ``DataPart`` from this, not from a hand-rolled ``{"a2ui": ...}``
65
+ literal: :func:`a2ui_data_part` (VB's outbound surface) calls it below, and master-agent's
66
+ outbound click builds its protobuf ``Value`` from it directly — the same way the shipped
67
+ HITL resume part builds its ``Struct`` (``clients/specialist_client.py``). One accessor
68
+ writing the wrapper key is what R10/R11 mean by "the unwrap/wrap rule has exactly one
69
+ owner": a second place that writes ``{"a2ui": ...}`` by hand is a second copy to drift.
70
+ """
71
+ return {STEP_UI_KEY: messages}
72
+
73
+
74
+ def a2ui_data_part(messages: list[dict[str, Any]]) -> dict[str, Any]:
75
+ """One A2A DataPart carrying a list of A2UI messages.
76
+
77
+ Wrapped under ``STEP_UI_KEY`` rather than sent as the spec's own bare list — see the
78
+ module docstring for why the transport, not the extension spec, wins this one.
79
+ """
80
+ return {"kind": "data", "data": a2ui_part_data(messages), "metadata": {"mimeType": MIME_TYPE}}
81
+
82
+
83
+ #: The ui-component ref an A2UI surface was drawn from, on the ARTIFACT's metadata.
84
+ #:
85
+ #: **Not in the data part.** :func:`a2ui_part_data` has exactly one owner and BOTH directions build
86
+ #: from it — master-agent's outbound CLICK included — and a click is drawn from no component, so a
87
+ #: ref added there would ride one and mean nothing. Artifact metadata is also the copy that
88
+ #: reliably survives the a2a-sdk's proto mapping (see the module docstring), which is precisely the
89
+ #: property this key needs: its reader is master-agent, on the proto side.
90
+ #:
91
+ #: Same name as :data:`CARDS_COMPONENT_KEY` and the same meaning, deliberately — one word for "what
92
+ #: was drawn" across both render formats — but a different PLACE, because the two parts differ in
93
+ #: what they can carry (see :func:`cards_data_part`).
94
+ A2UI_COMPONENT_KEY = "component"
95
+
96
+ #: The ui-component REGISTRY KEY the same surface was drawn from (FDP-3036), beside the ref above.
97
+ #:
98
+ #: **Why both, when they are usually the same string.** The ref is the catalog name the drawing
99
+ #: agent's tool enumerates — it is what :data:`OPEN_SURFACES_KEY` is keyed by, because that is the
100
+ #: only name the peer's own tool can take as an argument. The key is what the UI Components
101
+ #: registry resolves a PUBLISHED BUNDLE by, which is what a reader that wants to draw the surface
102
+ #: again needs. ``ui_component_render_tool``'s ``entry.get("key") or ref`` is where the two part
103
+ #: company: a component whose spec was not named after its key has a ref that resolves to nothing
104
+ #: in the registry. Collapsing them into one field would make that case render an empty frame.
105
+ #:
106
+ #: Omitted when blank, the rule every other optional key here follows —
107
+ #: :func:`component_key_of_artifact` answers ``""`` for a peer built before this existed, and
108
+ #: deliberately does NOT fall back to the ref.
109
+ A2UI_COMPONENT_REGISTRY_KEY = "componentKey"
110
+
111
+
112
+ def a2ui_artifact_metadata(component: str = "", component_key: str = "") -> dict[str, Any]:
113
+ """The metadata of an artifact carrying an A2UI surface: the mime marker, and what was drawn.
114
+
115
+ ``component`` is OMITTED rather than sent blank — the same rule :func:`cards_data_part` applies
116
+ to its own copy: absent reads as "this runtime does not send it", while ``""`` would read as a
117
+ component whose ref is genuinely blank. A peer built before this key existed sends the marker
118
+ alone, and :func:`component_of_artifact` answers ``""`` for it.
119
+
120
+ ``component_key`` (FDP-3036) rides beside it under the same rule. See
121
+ :data:`A2UI_COMPONENT_REGISTRY_KEY` for why it is a second field and not the same one.
122
+ """
123
+ metadata: dict[str, Any] = {"mimeType": MIME_TYPE}
124
+ if component:
125
+ metadata[A2UI_COMPONENT_KEY] = component
126
+ if component_key:
127
+ metadata[A2UI_COMPONENT_REGISTRY_KEY] = component_key
128
+ return metadata
129
+
130
+
131
+ def component_of_artifact(metadata: Any) -> str:
132
+ """The ui-component ref an A2UI artifact names, or ``""`` when it names none.
133
+
134
+ Total, and blank for every shape that is not a string under the key: the caller uses this to
135
+ decide whether a LATER turn can update the surface in place, and a non-string ref would name a
136
+ component no catalog can resolve. Falling back to "nothing is open" costs a redraw; guessing
137
+ costs an update aimed at a surface that does not exist.
138
+ """
139
+ if not isinstance(metadata, dict):
140
+ return ""
141
+ ref = metadata.get(A2UI_COMPONENT_KEY)
142
+ return ref if isinstance(ref, str) else ""
143
+
144
+
145
+ def component_key_of_artifact(metadata: Any) -> str:
146
+ """The ui-component REGISTRY KEY an A2UI artifact names, or ``""`` when it names none.
147
+
148
+ Total and strictly typed, like its ref twin above — and with one rule of its own:
149
+ **it never falls back to the ref.** A ref that is not a key resolves to nothing in the UI
150
+ Components registry, so substituting one would turn "I do not know what was drawn" into a
151
+ lookup that misses and renders an empty frame — the exact failure this key was added to stop.
152
+ Blank means the drawing runtime predates FDP-3036, and a reader draws nothing for it.
153
+ """
154
+ if not isinstance(metadata, dict):
155
+ return ""
156
+ key = metadata.get(A2UI_COMPONENT_REGISTRY_KEY)
157
+ return key if isinstance(key, str) else ""
158
+
159
+
160
+ #: Surfaces the CALLER already has on the employee's screen that the peer being called drew, as
161
+ #: ``{component_ref: surface_id}``, on the outbound MESSAGE's metadata.
162
+ #:
163
+ #: **The problem it solves.** A consult runs on a fresh peer thread per tool call, so
164
+ #: ``update_ui_component`` — which finds an open surface by scanning the current thread's own
165
+ #: messages — sees nothing a previous consult drew and can only answer "call render_ui_component
166
+ #: first". The employee then gets a second copy of the form for every field they fill in. Handing
167
+ #: the open surfaces back is what lets the peer change the one already on screen.
168
+ #:
169
+ #: **Keyed by REF, not by surface id.** The peer's own tool takes a ``component`` argument that is a
170
+ #: ``Literal`` over its catalog refs; a surface id is not something it can name. One entry per ref
171
+ #: also settles the duplicate case by construction — a form drawn twice has one live surface, the
172
+ #: newest, and the caller resolves that before sending.
173
+ #:
174
+ #: **Message metadata, not a data part.** Every reader on the inbound side treats a data part's
175
+ #: PRESENCE as a branch selector (a resume, a click), so a hint riding one would change what the
176
+ #: turn IS. Metadata is inert: a peer that does not know this key ignores it and behaves exactly as
177
+ #: it did before, which is the compatibility this needs in both directions.
178
+ OPEN_SURFACES_KEY = "open_surfaces"
179
+
180
+
181
+ def open_surfaces_metadata(open_surfaces: dict[str, str] | None) -> dict[str, Any]:
182
+ """Message metadata naming the peer's surfaces already on screen — ``{}`` when there are none.
183
+
184
+ Empty means the key is ABSENT rather than present-and-empty: absent and empty mean the same
185
+ thing to a reader, and an empty object on every message would be noise on a wire whose other
186
+ optional keys are all omitted the same way (see :func:`cards_data_part`).
187
+ """
188
+ clean = {ref: sid for ref, sid in (open_surfaces or {}).items() if isinstance(ref, str) and ref and sid}
189
+ return {OPEN_SURFACES_KEY: clean} if clean else {}
190
+
191
+
192
+ def open_surfaces_of_message(metadata: Any) -> dict[str, str]:
193
+ """``{component_ref: surface_id}`` off an inbound message's metadata — ``{}`` when it names none.
194
+
195
+ Total and strictly typed: every entry that is not a non-blank ``str`` → non-blank ``str`` is
196
+ dropped rather than passed on. This value decides which surface a later ``updateDataModel`` is
197
+ aimed at, and an operation aimed at a surface that does not exist is silently invisible — A2UI
198
+ has no fallback. Dropping an entry costs one redraw, which is exactly the behaviour that
199
+ existed before this key.
200
+ """
201
+ if not isinstance(metadata, dict):
202
+ return {}
203
+ raw = metadata.get(OPEN_SURFACES_KEY)
204
+ if not isinstance(raw, dict):
205
+ return {}
206
+ return {ref: sid for ref, sid in raw.items() if isinstance(ref, str) and ref and isinstance(sid, str) and sid}
207
+
208
+
209
+ def looks_like_a2ui(data: Any) -> bool:
210
+ """Is this a non-empty list of A2UI messages? Structure only — no marker consulted."""
211
+ return (
212
+ isinstance(data, list)
213
+ and bool(data)
214
+ and all(isinstance(m, dict) and bool(_MESSAGE_KEYS & m.keys()) for m in data)
215
+ )
216
+
217
+
218
+ def a2ui_messages_of_part(part: Any) -> list[dict[str, Any]] | None:
219
+ """The A2UI messages in one raw-JSON part, or ``None`` when it is not an a2ui part.
220
+
221
+ Accepts the dict-wrapped shape this module WRITES (``data: {STEP_UI_KEY: [...]}``) and,
222
+ liberally, the a2ui.org spec's own literal bare-list shape — marked or unmarked either
223
+ way. See the module docstring: only the write side changed, the read side stays liberal.
224
+ """
225
+ if not isinstance(part, dict) or part.get("kind") not in (None, "data"):
226
+ return None
227
+ data = part.get("data")
228
+ meta = part.get("metadata")
229
+ marked = isinstance(meta, dict) and meta.get("mimeType") == MIME_TYPE
230
+
231
+ messages = data.get(STEP_UI_KEY) if isinstance(data, dict) else data
232
+ if marked and isinstance(messages, list):
233
+ return [m for m in messages if isinstance(m, dict)]
234
+ if looks_like_a2ui(messages):
235
+ return list(messages)
236
+ return None
237
+
238
+
239
+ #: The employee's own utterance for one *Send message* click, crossing A2A as its own marked part.
240
+ #:
241
+ #: **Why it is not an A2UI message.** ``_MESSAGE_KEYS`` above is the closed set the v0.9 protocol
242
+ #: defines, and "the employee said this" is not one of them — folding it in would make a private
243
+ #: addition to a standard vocabulary that every other reader of these parts would then have to
244
+ #: know about. It is a fact about the TURN, not a change to a surface, so it travels beside the
245
+ #: surface parts rather than inside them, with a marker of this stack's own.
246
+ SPOKEN_MIME = "application/vnd.keystone.a2ui-spoken+json"
247
+
248
+ #: The wrapper key, for the same transport reason ``STEP_UI_KEY`` is used above: ``DataPart.data``
249
+ #: is typed ``dict[str, Any]`` by the a2a-sdk, so a bare string could not cross either.
250
+ SPOKEN_KEY = "spoken"
251
+
252
+
253
+ def spoken_data_part(text: str) -> dict[str, Any]:
254
+ """One A2A DataPart carrying what the employee said by pressing a control.
255
+
256
+ A click on a control whose author declared it *Send message* IS the employee saying that
257
+ sentence, and only the runtime that holds the component's handler table can tell which clicks
258
+ those are (``visual-builder``'s ``services.runtime.a2ui_action.spoken_text``). Downstream hops
259
+ cannot re-derive it, so it has to travel — this is the shape it travels in on the A2A hop, the
260
+ twin of the AG-UI door's ``TEXT_MESSAGE_START(role="user")``.
261
+ """
262
+ return {"kind": "data", "data": {SPOKEN_KEY: text}, "metadata": {"mimeType": SPOKEN_MIME}}
263
+
264
+
265
+ def spoken_of_part(part: Any) -> str | None:
266
+ """What the employee said, from one raw-JSON part — ``None`` when the part is not one of these.
267
+
268
+ Strictly marker-gated, unlike :func:`a2ui_messages_of_part`: an A2UI message is recognisable by
269
+ its own structure, while this payload is one string under one key and structure alone would
270
+ claim any ``{"spoken": …}`` a peer happened to send. The marker is read from the part AND from
271
+ the artifact metadata copy the emitter stamps (the part-level one does not reliably survive the
272
+ a2a-sdk's proto mapping — see the module docstring), so a caller passes the artifact's metadata
273
+ in when it has decoded a proto part.
274
+ """
275
+ if not isinstance(part, dict) or part.get("kind") not in (None, "data"):
276
+ return None
277
+ meta = part.get("metadata")
278
+ if not (isinstance(meta, dict) and meta.get("mimeType") == SPOKEN_MIME):
279
+ return None
280
+ data = part.get("data")
281
+ text = data.get(SPOKEN_KEY) if isinstance(data, dict) else None
282
+ return text if isinstance(text, str) and text.strip() else None
283
+
284
+
285
+ #: A finished Google Chat ``cardsV2`` payload, crossing A2A as its own marked part (FDP-3244 /
286
+ #: FR-127a).
287
+ #:
288
+ #: **Its own mime, not** :data:`MIME_TYPE`. ``_MESSAGE_KEYS`` above is the closed set the A2UI v0.9
289
+ #: protocol defines and ``cardsV2`` is not one of them: this is a rendering the CHANNEL draws, not a
290
+ #: surface an A2UI renderer draws. Folding it into the A2UI part would make a private addition to a
291
+ #: standard vocabulary that every other reader of these parts would then have to know about — the
292
+ #: same reasoning that keeps :data:`SPOKEN_MIME` separate.
293
+ #:
294
+ #: The payload is the component author's, published through ui-component and validated by ITS gate
295
+ #: (``services/ui-component/domain/publication.py``). Nothing on this wire reads inside ``cards``.
296
+ CARDS_MIME = "application/vnd.keystone.cards-v2+json"
297
+
298
+ CARDS_KEY = "cardsV2"
299
+ CARDS_SURFACE_KEY = "surfaceId"
300
+ CARDS_COMPONENT_KEY = "component"
301
+
302
+
303
+ def cards_data_part(cards: list[dict[str, Any]], *, surface_id: str, component: str = "") -> dict[str, Any]:
304
+ """One A2A DataPart carrying a finished ``cardsV2`` payload.
305
+
306
+ ``surface_id`` rides IN the payload rather than being recovered from it, which is the one
307
+ structural difference from :func:`a2ui_data_part`: an A2UI message names its own ``surfaceId``
308
+ on every operation, and a Google card names nothing — it is a channel's render format, with no
309
+ place for our routing key. Without it a click has no drawer to go back to, so it is a required
310
+ argument here and a required field on read.
311
+
312
+ ``component`` names WHAT was drawn (it becomes the A2UI action's ``sourceComponentId``) and is
313
+ optional in a way ``surface_id`` is not. Omitted rather than sent empty: a reader treats absent
314
+ as "this runtime does not send it", while ``""`` would read as a component whose ref is blank.
315
+ """
316
+ data: dict[str, Any] = {CARDS_KEY: cards, CARDS_SURFACE_KEY: surface_id}
317
+ if component:
318
+ data[CARDS_COMPONENT_KEY] = component
319
+ return {"kind": "data", "data": data, "metadata": {"mimeType": CARDS_MIME}}
320
+
321
+
322
+ def cards_of_part(part: Any) -> dict[str, Any] | None:
323
+ """The card payload in one raw-JSON part, or ``None`` when it is not one.
324
+
325
+ Returns ``{"cards", "surface_id", "component"}`` — flat keys rather than the wire's camelCase,
326
+ because the callers are Python and the wire shape has exactly one owner (this module).
327
+
328
+ Liberal about the MARKER and strict about the SHAPE, mirroring :func:`a2ui_messages_of_part`:
329
+ part-level metadata does not reliably survive the a2a-sdk's proto mapping, so an unmarked part
330
+ with the right structure is accepted — but a payload with no ``surfaceId``, or with an empty
331
+ card list, is refused outright. Both are unroutable or unrenderable, and minting a substitute id
332
+ would file a later click under an agent that never drew anything.
333
+ """
334
+ if not isinstance(part, dict) or part.get("kind") not in (None, "data"):
335
+ return None
336
+ data = part.get("data")
337
+ if not isinstance(data, dict):
338
+ return None
339
+ cards = data.get(CARDS_KEY)
340
+ if not isinstance(cards, list) or not cards or not all(isinstance(c, dict) for c in cards):
341
+ return None
342
+ surface_id = data.get(CARDS_SURFACE_KEY)
343
+ if not isinstance(surface_id, str) or not surface_id:
344
+ return None
345
+ component = data.get(CARDS_COMPONENT_KEY)
346
+ return {
347
+ "cards": list(cards),
348
+ "surface_id": surface_id,
349
+ "component": component if isinstance(component, str) else "",
350
+ }
351
+
352
+
353
+ #: A finished Microsoft Teams Adaptive Card, crossing A2A as its own marked part (FDP-5466 — the
354
+ #: sign-in card a consulted specialist draws on Teams). The Teams twin of :data:`CARDS_MIME`, with
355
+ #: the same rules: its own mime, ``surfaceId`` in the payload, nothing on this wire reads the card.
356
+ ADAPTIVE_CARD_MIME = "application/vnd.keystone.adaptive-card+json"
357
+
358
+ ADAPTIVE_CARD_KEY = "adaptiveCard"
359
+
360
+
361
+ def adaptive_card_data_part(card: dict[str, Any], *, surface_id: str) -> dict[str, Any]:
362
+ """One A2A DataPart carrying a finished Adaptive Card."""
363
+ return {
364
+ "kind": "data",
365
+ "data": {ADAPTIVE_CARD_KEY: card, CARDS_SURFACE_KEY: surface_id},
366
+ "metadata": {"mimeType": ADAPTIVE_CARD_MIME},
367
+ }
368
+
369
+
370
+ def adaptive_card_of_part(part: Any) -> dict[str, Any] | None:
371
+ """``{"card", "surface_id"}`` from one raw-JSON part, or ``None`` when it is not one.
372
+
373
+ Same posture as :func:`cards_of_part`: liberal about the marker, strict about the shape. A
374
+ payload with no ``surfaceId`` or an empty card is refused.
375
+ """
376
+ if not isinstance(part, dict) or part.get("kind") not in (None, "data"):
377
+ return None
378
+ data = part.get("data")
379
+ if not isinstance(data, dict):
380
+ return None
381
+ card = data.get(ADAPTIVE_CARD_KEY)
382
+ if not isinstance(card, dict) or not card:
383
+ return None
384
+ surface_id = data.get(CARDS_SURFACE_KEY)
385
+ if not isinstance(surface_id, str) or not surface_id:
386
+ return None
387
+ return {"card": dict(card), "surface_id": surface_id}
388
+
389
+
390
+ def action_messages(action: dict[str, Any]) -> list[dict[str, Any]]:
391
+ """The client→server message list for one user action (the flat click dict, wrapped)."""
392
+ return [{"version": VERSION, "action": dict(action)}]
393
+
394
+
395
+ def action_of(messages: list[dict[str, Any]]) -> dict[str, Any] | None:
396
+ """The first action message's payload, or ``None``."""
397
+ for message in messages:
398
+ if isinstance(message, dict) and isinstance(message.get("action"), dict):
399
+ return message["action"]
400
+ return None
401
+
402
+
403
+ def surface_id_of(messages: list[dict[str, Any]]) -> str:
404
+ """The ONE surface this message list addresses — '' when none or more than one.
405
+
406
+ Ambiguity is refused rather than guessed: the consumer keys a routing table on this,
407
+ and filing a two-surface batch under either id would misroute the other's clicks.
408
+ """
409
+ ids = {
410
+ str(body["surfaceId"])
411
+ for message in messages
412
+ if isinstance(message, dict)
413
+ for body in message.values()
414
+ if isinstance(body, dict) and body.get("surfaceId")
415
+ }
416
+ return ids.pop() if len(ids) == 1 else ""
417
+
418
+
419
+ def restore_ints(value: Any) -> Any:
420
+ """Recursively turn whole-number floats back into ints.
421
+
422
+ protobuf ``Struct``'s only numeric type is a double, so every int crossing the
423
+ a2a-sdk arrives as a float (``gate_seq: 2 → 2.0``). A genuine ``2.0`` also becomes
424
+ ``2`` — accepted: A2UI payloads carry no semantically-float whole numbers.
425
+ """
426
+ if isinstance(value, float) and value.is_integer():
427
+ return int(value)
428
+ if isinstance(value, list):
429
+ return [restore_ints(v) for v in value]
430
+ if isinstance(value, dict):
431
+ return {k: restore_ints(v) for k, v in value.items()}
432
+ return value
433
+
434
+
435
+ # ── FDP-6261 / FR-162 — the caller's turn and an agent's files, over A2A ──────────────────────────
436
+
437
+ #: The caller's AG-UI turn, on the outbound MESSAGE's metadata: ``{"run_id", "conversation_id"}``.
438
+ #:
439
+ #: master-agent sends it when it consults or hands off, so a specialist that makes a file records the
440
+ #: run and the conversation the EMPLOYEE is in (agent-conversations shows an agent's file only when
441
+ #: its ``invocation_id`` is one of that conversation's runs). Message metadata for the reason
442
+ #: :data:`OPEN_SURFACES_KEY` gives: inert to a peer that does not read it.
443
+ TURN_KEY = "keystone"
444
+ #: The bound file-artifact puts on both ids (``thread_id`` / ``invocation_id``, ``max_length=127``).
445
+ _TURN_ID_MAX = 127
446
+
447
+ #: A file an agent made, as a ``custom`` stream item and an AG-UI ``activityType``. Spelled out
448
+ #: rather than imported: the producer is the agent SDK (``keystone.agent_sdk.clients.file_artifact``),
449
+ #: which this library does not depend on. A contract with Front Desk: do not rename.
450
+ FILE_ACTIVITY = "keystone.file"
451
+ #: The artifact-level marker of a file artifact. Artifact metadata survives the a2a-sdk's proto
452
+ #: mapping (see the module docstring); the data part's own ``type`` is the structural check.
453
+ FILE_MIME_TYPE = "application/vnd.keystone.file+json"
454
+ _FILE_KEYS = ("artifact_id", "name", "mime_type", "size")
455
+
456
+
457
+ def turn_metadata(*, run_id: str = "", conversation_id: str = "") -> dict[str, Any]:
458
+ """Message metadata naming the caller's turn — ``{}`` when it names nothing."""
459
+ ids = {
460
+ key: value.strip()
461
+ for key, value in (("run_id", run_id), ("conversation_id", conversation_id))
462
+ if isinstance(value, str) and value.strip()
463
+ }
464
+ return {TURN_KEY: ids} if ids else {}
465
+
466
+
467
+ def turn_of_message(metadata: Any) -> tuple[str, str]:
468
+ """``(run_id, conversation_id)`` off an inbound message's metadata; ``""`` for each it lacks.
469
+
470
+ An id that is not a non-blank string, or is longer than file-artifact accepts, is dropped: a
471
+ file without it is hidden, which fails closed, while a wrong one would bind it to a run it does
472
+ not belong to.
473
+ """
474
+ raw = metadata.get(TURN_KEY) if isinstance(metadata, dict) else None
475
+ if not isinstance(raw, dict):
476
+ return "", ""
477
+
478
+ def _id(key: str) -> str:
479
+ value = raw.get(key)
480
+ value = value.strip() if isinstance(value, str) else ""
481
+ return value if len(value) <= _TURN_ID_MAX else ""
482
+
483
+ return _id("run_id"), _id("conversation_id")
484
+
485
+
486
+ def file_content(data: Any) -> dict[str, Any] | None:
487
+ """``{artifact_id, name, mime_type, size}`` of a file item, or ``None`` when it is not one.
488
+
489
+ Reads the SDK's ``custom`` item and the data part below alike (both carry ``type``). ``size``
490
+ is restored to an int: it crosses the a2a-sdk's protobuf ``Struct`` as a double.
491
+ """
492
+ if not isinstance(data, dict) or data.get("type") != FILE_ACTIVITY:
493
+ return None
494
+ artifact_id, name, mime_type, size = (data.get(key) for key in _FILE_KEYS)
495
+ if not (isinstance(artifact_id, str) and artifact_id and isinstance(name, str) and isinstance(mime_type, str)):
496
+ return None
497
+ if isinstance(size, float) and size.is_integer():
498
+ size = int(size)
499
+ if isinstance(size, bool) or not isinstance(size, int) or size < 0:
500
+ return None
501
+ return {"artifact_id": artifact_id, "name": name, "mime_type": mime_type, "size": size}
502
+
503
+
504
+ def file_data_part(content: dict[str, Any]) -> dict[str, Any]:
505
+ """One A2A DataPart carrying a file an agent made (``content`` as :func:`file_content` returns)."""
506
+ return {"kind": "data", "data": {"type": FILE_ACTIVITY, **content}, "metadata": {"mimeType": FILE_MIME_TYPE}}
507
+
508
+
509
+ def file_artifact(content: dict[str, Any]) -> dict[str, Any]:
510
+ """The A2A v0.3 artifact for one file: its own id, one data part, the marker on its metadata."""
511
+ return {
512
+ "artifactId": f"file-{content['artifact_id']}",
513
+ "parts": [file_data_part(content)],
514
+ "metadata": {"mimeType": FILE_MIME_TYPE},
515
+ }
516
+
517
+
518
+ def file_of_part(part: Any) -> dict[str, Any] | None:
519
+ """The file in one A2A part (``{"kind": "data", "data": …}``), or ``None`` when it carries none."""
520
+ if not isinstance(part, dict):
521
+ return None
522
+ return file_content(part.get("data"))