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.
- keystone_agui-0.1.1/PKG-INFO +12 -0
- keystone_agui-0.1.1/__init__.py +127 -0
- keystone_agui-0.1.1/a2a_ext.py +522 -0
- keystone_agui-0.1.1/a2ui.py +242 -0
- keystone_agui-0.1.1/actions.py +57 -0
- keystone_agui-0.1.1/bindings.py +350 -0
- keystone_agui-0.1.1/blocks.py +77 -0
- keystone_agui-0.1.1/catalog_check.py +469 -0
- keystone_agui-0.1.1/chat_surfaces.py +196 -0
- keystone_agui-0.1.1/denial.py +363 -0
- keystone_agui-0.1.1/file_surfaces.py +207 -0
- keystone_agui-0.1.1/gate.py +156 -0
- keystone_agui-0.1.1/generative/__init__.py +20 -0
- keystone_agui-0.1.1/generative/catalog.py +361 -0
- keystone_agui-0.1.1/generative/channel.py +41 -0
- keystone_agui-0.1.1/generative/display_only.py +135 -0
- keystone_agui-0.1.1/generative/frames.py +37 -0
- keystone_agui-0.1.1/generative/grounding.py +182 -0
- keystone_agui-0.1.1/generative/history.py +116 -0
- keystone_agui-0.1.1/generative/patch.py +358 -0
- keystone_agui-0.1.1/generative/pies.py +92 -0
- keystone_agui-0.1.1/generative/prepare.py +140 -0
- keystone_agui-0.1.1/generative/refine.py +59 -0
- keystone_agui-0.1.1/generative/reply.py +156 -0
- keystone_agui-0.1.1/generative/stream.py +121 -0
- keystone_agui-0.1.1/generative/tools.py +227 -0
- keystone_agui-0.1.1/generative/tree.py +625 -0
- keystone_agui-0.1.1/generative/units.py +259 -0
- keystone_agui-0.1.1/inbound.py +671 -0
- keystone_agui-0.1.1/keystone_agui.egg-info/PKG-INFO +12 -0
- keystone_agui-0.1.1/keystone_agui.egg-info/SOURCES.txt +89 -0
- keystone_agui-0.1.1/keystone_agui.egg-info/dependency_links.txt +1 -0
- keystone_agui-0.1.1/keystone_agui.egg-info/requires.txt +8 -0
- keystone_agui-0.1.1/keystone_agui.egg-info/top_level.txt +1 -0
- keystone_agui-0.1.1/native_card.py +158 -0
- keystone_agui-0.1.1/outbound.py +48 -0
- keystone_agui-0.1.1/pyproject.toml +54 -0
- keystone_agui-0.1.1/schemas/basic_catalog.json +1383 -0
- keystone_agui-0.1.1/schemas/common_types.json +305 -0
- keystone_agui-0.1.1/schemas/keystone_catalog.json +2113 -0
- keystone_agui-0.1.1/schemas/keystone_components.json +371 -0
- keystone_agui-0.1.1/setup.cfg +4 -0
- keystone_agui-0.1.1/sign_in.py +368 -0
- keystone_agui-0.1.1/surfaces.py +134 -0
- keystone_agui-0.1.1/tests/test_a2a_ext.py +322 -0
- keystone_agui-0.1.1/tests/test_a2a_files.py +68 -0
- keystone_agui-0.1.1/tests/test_a2ui.py +120 -0
- keystone_agui-0.1.1/tests/test_actions.py +30 -0
- keystone_agui-0.1.1/tests/test_bindings.py +376 -0
- keystone_agui-0.1.1/tests/test_blocks.py +54 -0
- keystone_agui-0.1.1/tests/test_catalog_check.py +259 -0
- keystone_agui-0.1.1/tests/test_catalog_check_multi.py +70 -0
- keystone_agui-0.1.1/tests/test_chat_surfaces.py +133 -0
- keystone_agui-0.1.1/tests/test_denial.py +595 -0
- keystone_agui-0.1.1/tests/test_file_surfaces.py +85 -0
- keystone_agui-0.1.1/tests/test_gate.py +122 -0
- keystone_agui-0.1.1/tests/test_generative_catalog.py +89 -0
- keystone_agui-0.1.1/tests/test_generative_golden.py +138 -0
- keystone_agui-0.1.1/tests/test_generative_lib.py +257 -0
- keystone_agui-0.1.1/tests/test_generative_patch.py +492 -0
- keystone_agui-0.1.1/tests/test_generative_tree.py +282 -0
- keystone_agui-0.1.1/tests/test_inbound.py +684 -0
- keystone_agui-0.1.1/tests/test_keystone_catalog.py +131 -0
- keystone_agui-0.1.1/tests/test_keystone_components.py +131 -0
- keystone_agui-0.1.1/tests/test_sign_in.py +312 -0
- keystone_agui-0.1.1/tests/test_surface_tree.py +138 -0
- keystone_agui-0.1.1/tests/test_surfaces.py +120 -0
- keystone_agui-0.1.1/tests/test_units.py +174 -0
- keystone_agui-0.1.1/tests/test_unknown_components_fdp6967.py +46 -0
- 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"))
|