Skip to content

Commit 215b64a

Browse files
Fix multi-surface validation and inline catalog handling
- Validator: support multiple surfaces with different root IDs by tracking per-surface roots instead of a single global root - Validator: distinguish initial renders (beginRendering/createSurface) from incremental updates; skip root and orphan checks on updates while still catching cycles, self-refs, and duplicates - Manager: merge inline catalog components onto the base catalog instead of using inline-only; allow both inlineCatalogs and supportedCatalogIds in client capabilities - Agent: remove schema from system prompt (provided per-request via client capabilities); pass client_ui_capabilities through to catalog selection and validation - Executor: extract a2uiClientCapabilities from all DataParts (not just request parts) so UI events use the correct catalog - Client: inline OrgChart action schema (was unresolvable $ref) and add path-reference oneOf for chain (matches agent examples)
1 parent e3c1ec6 commit 215b64a

File tree

4 files changed

+232
-30
lines changed

4 files changed

+232
-30
lines changed

agent_sdks/python/src/a2ui/core/schema/manager.py

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
import importlib.resources
2020
from typing import List, Dict, Any, Optional, Callable
2121
from dataclasses import dataclass, field
22-
from .utils import load_from_bundled_resource, deep_update
22+
from .utils import load_from_bundled_resource
2323
from ..inference_strategy import InferenceStrategy
2424
from .constants import *
2525
from .catalog import CatalogConfig, A2uiCatalog
@@ -103,8 +103,11 @@ def _select_catalog(
103103
"""Selects the component catalog for the prompt based on client capabilities.
104104
105105
Selection priority:
106-
1. First inline catalog if provided (and accepted by the agent).
107-
2. First client-supported catalog ID that is also supported by the agent.
106+
1. If inline catalogs are provided (and accepted by the agent), their
107+
components are merged on top of a base catalog. The base is determined
108+
by supportedCatalogIds (if also provided) or the agent's default catalog.
109+
2. If only supportedCatalogIds is provided, pick the first mutually
110+
supported catalog.
108111
3. Fallback to the first agent-supported catalog (usually the bundled catalog).
109112
110113
Args:
@@ -114,8 +117,8 @@ def _select_catalog(
114117
Returns:
115118
The resolved A2uiCatalog.
116119
Raises:
117-
ValueError: If capabilities are ambiguous (both inline_catalogs and supported_catalog_ids are provided), if inline
118-
catalogs are sent but not accepted, or if no mutually supported catalog is found.
120+
ValueError: If inline catalogs are sent but not accepted, or if no
121+
mutually supported catalog is found.
119122
"""
120123
if not self._supported_catalogs:
121124
raise ValueError("No supported catalogs found.") # This should not happen.
@@ -136,20 +139,25 @@ def _select_catalog(
136139
" capabilities. However, the agent does not accept inline catalogs."
137140
)
138141

139-
if inline_catalogs and client_supported_catalog_ids:
140-
raise ValueError(
141-
f"Both '{INLINE_CATALOGS_KEY}' and '{SUPPORTED_CATALOG_IDS_KEY}' "
142-
"are provided in client UI capabilities. Only one is allowed."
143-
)
144-
145142
if inline_catalogs:
146-
# Load the first inline catalog schema.
147-
inline_catalog_schema = inline_catalogs[0]
148-
inline_catalog_schema = self._apply_modifiers(inline_catalog_schema)
149-
150-
# Deep merge the standard catalog properties with the inline catalog
151-
merged_schema = copy.deepcopy(self._supported_catalogs[0].catalog_schema)
152-
deep_update(merged_schema, inline_catalog_schema)
143+
# Determine the base catalog: use supportedCatalogIds if provided,
144+
# otherwise fall back to the agent's default catalog.
145+
base_catalog = self._supported_catalogs[0]
146+
if client_supported_catalog_ids:
147+
agent_supported_catalogs = {
148+
c.catalog_id: c for c in self._supported_catalogs
149+
}
150+
for cscid in client_supported_catalog_ids:
151+
if cscid in agent_supported_catalogs:
152+
base_catalog = agent_supported_catalogs[cscid]
153+
break
154+
155+
merged_schema = copy.deepcopy(base_catalog.catalog_schema)
156+
157+
for inline_catalog_schema in inline_catalogs:
158+
inline_catalog_schema = self._apply_modifiers(inline_catalog_schema)
159+
inline_components = inline_catalog_schema.get(CATALOG_COMPONENTS_KEY, {})
160+
merged_schema[CATALOG_COMPONENTS_KEY].update(inline_components)
153161

154162
return A2uiCatalog(
155163
version=self._version,

agent_sdks/python/tests/core/schema/test_validator.py

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1037,3 +1037,197 @@ def test_validate_global_recursion_limit_exceeded(self, test_catalog):
10371037
payload = self.make_payload(test_catalog, data_model=deep_data)
10381038
with pytest.raises(ValueError, match="Global recursion limit exceeded"):
10391039
test_catalog.validator.validate(payload)
1040+
1041+
# --- Multi-surface tests ---
1042+
1043+
def test_validate_multi_surface_v08(self, catalog_0_8):
1044+
"""Tests that multiple surfaces with different root IDs validate correctly."""
1045+
payload = [
1046+
{"beginRendering": {"surfaceId": "surface-a", "root": "root-a"}},
1047+
{"beginRendering": {"surfaceId": "surface-b", "root": "root-b"}},
1048+
{
1049+
"surfaceUpdate": {
1050+
"surfaceId": "surface-a",
1051+
"components": [
1052+
{"id": "root-a", "component": {"Card": {"child": "child-a"}}},
1053+
{"id": "child-a", "component": {"Text": {"text": "Hello A"}}},
1054+
],
1055+
}
1056+
},
1057+
{
1058+
"surfaceUpdate": {
1059+
"surfaceId": "surface-b",
1060+
"components": [
1061+
{"id": "root-b", "component": {"Card": {"child": "child-b"}}},
1062+
{"id": "child-b", "component": {"Text": {"text": "Hello B"}}},
1063+
],
1064+
}
1065+
},
1066+
]
1067+
# Should not raise - each surface has its own root
1068+
catalog_0_8.validator.validate(payload)
1069+
1070+
def test_validate_multi_surface_missing_root_v08(self, catalog_0_8):
1071+
"""Tests that missing root in one surface still fails validation."""
1072+
payload = [
1073+
{"beginRendering": {"surfaceId": "surface-a", "root": "root-a"}},
1074+
{"beginRendering": {"surfaceId": "surface-b", "root": "root-b"}},
1075+
{
1076+
"surfaceUpdate": {
1077+
"surfaceId": "surface-a",
1078+
"components": [
1079+
{"id": "root-a", "component": {"Text": {"text": "Hello A"}}},
1080+
],
1081+
}
1082+
},
1083+
{
1084+
"surfaceUpdate": {
1085+
"surfaceId": "surface-b",
1086+
"components": [
1087+
# Missing root-b, only has a non-root component
1088+
{"id": "not-root-b", "component": {"Text": {"text": "Hello B"}}},
1089+
],
1090+
}
1091+
},
1092+
]
1093+
with pytest.raises(ValueError, match="Missing root component.*root-b"):
1094+
catalog_0_8.validator.validate(payload)
1095+
1096+
# --- Incremental update tests ---
1097+
1098+
def test_incremental_update_no_root_v08(self, catalog_0_8):
1099+
"""Incremental update without root component should pass."""
1100+
payload = [
1101+
{
1102+
"surfaceUpdate": {
1103+
"surfaceId": "contact-card",
1104+
"components": [
1105+
{"id": "main_card", "component": {"Card": {"child": "col"}}},
1106+
{"id": "col", "component": {"Text": {"text": "Updated"}}},
1107+
],
1108+
}
1109+
},
1110+
]
1111+
# No beginRendering → incremental update → root check skipped
1112+
catalog_0_8.validator.validate(payload)
1113+
1114+
def test_incremental_update_no_root_v09(self, catalog_0_9):
1115+
"""Incremental update without root component should pass (v0.9)."""
1116+
payload = [
1117+
{
1118+
"version": "v0.9",
1119+
"updateComponents": {
1120+
"surfaceId": "contact-card",
1121+
"components": [
1122+
{"id": "card1", "component": "Card", "child": "text1"},
1123+
{"id": "text1", "component": "Text", "text": "Updated"},
1124+
],
1125+
},
1126+
},
1127+
]
1128+
# No createSurface → incremental update → root check skipped
1129+
catalog_0_9.validator.validate(payload)
1130+
1131+
def test_incremental_update_orphans_allowed_v08(self, catalog_0_8):
1132+
"""Incremental update with 'orphaned' components should pass."""
1133+
payload = [
1134+
{
1135+
"surfaceUpdate": {
1136+
"surfaceId": "contact-card",
1137+
"components": [
1138+
{"id": "text1", "component": {"Text": {"text": "Hello"}}},
1139+
{"id": "text2", "component": {"Text": {"text": "World"}}},
1140+
],
1141+
}
1142+
},
1143+
]
1144+
# These are disconnected but it's an incremental update
1145+
catalog_0_8.validator.validate(payload)
1146+
1147+
def test_incremental_update_self_ref_still_fails(self, test_catalog):
1148+
"""Self-references should still be caught in incremental updates."""
1149+
if test_catalog.version == VERSION_0_8:
1150+
payload = [
1151+
{
1152+
"surfaceUpdate": {
1153+
"surfaceId": "s1",
1154+
"components": [
1155+
{"id": "card1", "component": {"Card": {"child": "card1"}}},
1156+
],
1157+
}
1158+
},
1159+
]
1160+
else:
1161+
payload = [
1162+
{
1163+
"version": "v0.9",
1164+
"updateComponents": {
1165+
"surfaceId": "s1",
1166+
"components": [
1167+
{"id": "card1", "component": "Card", "child": "card1"},
1168+
],
1169+
},
1170+
},
1171+
]
1172+
with pytest.raises(ValueError, match="Self-reference detected"):
1173+
test_catalog.validator.validate(payload)
1174+
1175+
def test_incremental_update_cycle_still_fails(self, test_catalog):
1176+
"""Cycles should still be caught in incremental updates."""
1177+
if test_catalog.version == VERSION_0_8:
1178+
payload = [
1179+
{
1180+
"surfaceUpdate": {
1181+
"surfaceId": "s1",
1182+
"components": [
1183+
{"id": "a", "component": {"Card": {"child": "b"}}},
1184+
{"id": "b", "component": {"Card": {"child": "a"}}},
1185+
],
1186+
}
1187+
},
1188+
]
1189+
else:
1190+
payload = [
1191+
{
1192+
"version": "v0.9",
1193+
"updateComponents": {
1194+
"surfaceId": "s1",
1195+
"components": [
1196+
{"id": "a", "component": "Card", "child": "b"},
1197+
{"id": "b", "component": "Card", "child": "a"},
1198+
],
1199+
},
1200+
},
1201+
]
1202+
with pytest.raises(ValueError, match="Circular reference detected"):
1203+
test_catalog.validator.validate(payload)
1204+
1205+
def test_incremental_update_duplicates_still_fail(self, test_catalog):
1206+
"""Duplicate IDs should still be caught in incremental updates."""
1207+
if test_catalog.version == VERSION_0_8:
1208+
payload = [
1209+
{
1210+
"surfaceUpdate": {
1211+
"surfaceId": "s1",
1212+
"components": [
1213+
{"id": "text1", "component": {"Text": {"text": "A"}}},
1214+
{"id": "text1", "component": {"Text": {"text": "B"}}},
1215+
],
1216+
}
1217+
},
1218+
]
1219+
else:
1220+
payload = [
1221+
{
1222+
"version": "v0.9",
1223+
"updateComponents": {
1224+
"surfaceId": "s1",
1225+
"components": [
1226+
{"id": "text1", "component": "Text", "text": "A"},
1227+
{"id": "text1", "component": "Text", "text": "B"},
1228+
],
1229+
},
1230+
},
1231+
]
1232+
with pytest.raises(ValueError, match="Duplicate component ID"):
1233+
test_catalog.validator.validate(payload)

samples/agent/adk/contact_multiple_surfaces/agent_executor.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -75,23 +75,23 @@ async def execute(
7575
)
7676
for i, part in enumerate(context.message.parts):
7777
if isinstance(part.root, DataPart):
78+
# Extract client UI capabilities from any DataPart that has them
79+
if (
80+
agent.schema_manager.accepts_inline_catalogs
81+
and "metadata" in part.root.data
82+
and "a2uiClientCapabilities" in part.root.data["metadata"]
83+
):
84+
logger.info(f" Part {i}: Found 'a2uiClientCapabilities' in DataPart.")
85+
client_ui_capabilities = part.root.data["metadata"][
86+
"a2uiClientCapabilities"
87+
]
88+
7889
if "userAction" in part.root.data:
7990
logger.info(f" Part {i}: Found a2ui UI ClientEvent payload.")
8091
ui_event_part = part.root.data["userAction"]
8192
elif "request" in part.root.data:
8293
logger.info(f" Part {i}: Found 'request' in DataPart.")
8394
query = part.root.data["request"]
84-
85-
# Check for inline catalog
86-
if (
87-
agent.schema_manager.accepts_inline_catalogs
88-
and "metadata" in part.root.data
89-
and "a2uiClientCapabilities" in part.root.data["metadata"]
90-
):
91-
logger.info(f" Part {i}: Found 'a2uiClientCapabilities' in DataPart.")
92-
client_ui_capabilities = part.root.data["metadata"][
93-
"a2uiClientCapabilities"
94-
]
9595
else:
9696
logger.info(f" Part {i}: DataPart (data: {part.root.data})")
9797
elif isinstance(part.root, TextPart):

samples/agent/adk/contact_multiple_surfaces/prompt_builder.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ def get_text_prompt() -> str:
8888

8989
client_ui_capabilities_str = (
9090
'{"inlineCatalogs":[{"catalogId": "inline_catalog",'
91-
' "components":{"OrgChart":{"type":"object","properties":{"chain":{"type":"array","items":{"type":"object","properties":{"title":{"type":"string"},"name":{"type":"string"}},"required":["title","name"]}},"action":{"$ref":"#/definitions/Action"}},"required":["chain"]},"WebFrame":{"type":"object","properties":{"url":{"type":"string"},"html":{"type":"string"},"height":{"type":"number"},"interactionMode":{"type":"string","enum":["readOnly","interactive"]},"allowedEvents":{"type":"array","items":{"type":"string"}}}}}}]}'
91+
' "components":{"OrgChart":{"type":"object","properties":{"chain":{"oneOf":[{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]},{"type":"array","items":{"type":"object","properties":{"title":{"type":"string"},"name":{"type":"string"}},"required":["title","name"]}}]},"action":{"type":"object","properties":{"name":{"type":"string"},"context":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string"},"value":{"type":"object","properties":{"path":{"type":"string"},"literalString":{"type":"string"},"literalNumber":{"type":"number"},"literalBoolean":{"type":"boolean"}}}},"required":["key","value"]}}},"required":["name"]}},"required":["chain"]},"WebFrame":{"type":"object","properties":{"url":{"type":"string"},"html":{"type":"string"},"height":{"type":"number"},"interactionMode":{"type":"string","enum":["readOnly","interactive"]},"allowedEvents":{"type":"array","items":{"type":"string"}}}}}}]}'
9292
)
9393
client_ui_capabilities = json.loads(client_ui_capabilities_str)
9494
inline_catalog = schema_manager.get_selected_catalog(

0 commit comments

Comments
 (0)