Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 26 additions & 18 deletions agent_sdks/python/src/a2ui/core/schema/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
import importlib.resources
from typing import List, Dict, Any, Optional, Callable
from dataclasses import dataclass, field
from .utils import load_from_bundled_resource, deep_update
from .utils import load_from_bundled_resource
from ..inference_strategy import InferenceStrategy
from .constants import *
from .catalog import CatalogConfig, A2uiCatalog
Expand Down Expand Up @@ -103,8 +103,11 @@ def _select_catalog(
"""Selects the component catalog for the prompt based on client capabilities.

Selection priority:
1. First inline catalog if provided (and accepted by the agent).
2. First client-supported catalog ID that is also supported by the agent.
1. If inline catalogs are provided (and accepted by the agent), their
components are merged on top of a base catalog. The base is determined
by supportedCatalogIds (if also provided) or the agent's default catalog.
2. If only supportedCatalogIds is provided, pick the first mutually
supported catalog.
3. Fallback to the first agent-supported catalog (usually the bundled catalog).

Args:
Expand All @@ -114,8 +117,8 @@ def _select_catalog(
Returns:
The resolved A2uiCatalog.
Raises:
ValueError: If capabilities are ambiguous (both inline_catalogs and supported_catalog_ids are provided), if inline
catalogs are sent but not accepted, or if no mutually supported catalog is found.
ValueError: If inline catalogs are sent but not accepted, or if no
mutually supported catalog is found.
"""
if not self._supported_catalogs:
raise ValueError("No supported catalogs found.") # This should not happen.
Expand All @@ -136,20 +139,25 @@ def _select_catalog(
" capabilities. However, the agent does not accept inline catalogs."
)

if inline_catalogs and client_supported_catalog_ids:
raise ValueError(
f"Both '{INLINE_CATALOGS_KEY}' and '{SUPPORTED_CATALOG_IDS_KEY}' "
"are provided in client UI capabilities. Only one is allowed."
)

if inline_catalogs:
# Load the first inline catalog schema.
inline_catalog_schema = inline_catalogs[0]
inline_catalog_schema = self._apply_modifiers(inline_catalog_schema)

# Deep merge the standard catalog properties with the inline catalog
merged_schema = copy.deepcopy(self._supported_catalogs[0].catalog_schema)
deep_update(merged_schema, inline_catalog_schema)
# Determine the base catalog: use supportedCatalogIds if provided,
# otherwise fall back to the agent's default catalog.
base_catalog = self._supported_catalogs[0]
if client_supported_catalog_ids:
agent_supported_catalogs = {
c.catalog_id: c for c in self._supported_catalogs
}
for cscid in client_supported_catalog_ids:
if cscid in agent_supported_catalogs:
base_catalog = agent_supported_catalogs[cscid]
break

merged_schema = copy.deepcopy(base_catalog.catalog_schema)

for inline_catalog_schema in inline_catalogs:
inline_catalog_schema = self._apply_modifiers(inline_catalog_schema)
inline_components = inline_catalog_schema.get(CATALOG_COMPONENTS_KEY, {})
merged_schema[CATALOG_COMPONENTS_KEY].update(inline_components)

return A2uiCatalog(
version=self._version,
Expand Down
194 changes: 194 additions & 0 deletions agent_sdks/python/tests/core/schema/test_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -1037,3 +1037,197 @@ def test_validate_global_recursion_limit_exceeded(self, test_catalog):
payload = self.make_payload(test_catalog, data_model=deep_data)
with pytest.raises(ValueError, match="Global recursion limit exceeded"):
test_catalog.validator.validate(payload)

# --- Multi-surface tests ---

def test_validate_multi_surface_v08(self, catalog_0_8):
"""Tests that multiple surfaces with different root IDs validate correctly."""
payload = [
{"beginRendering": {"surfaceId": "surface-a", "root": "root-a"}},
{"beginRendering": {"surfaceId": "surface-b", "root": "root-b"}},
{
"surfaceUpdate": {
"surfaceId": "surface-a",
"components": [
{"id": "root-a", "component": {"Card": {"child": "child-a"}}},
{"id": "child-a", "component": {"Text": {"text": "Hello A"}}},
],
}
},
{
"surfaceUpdate": {
"surfaceId": "surface-b",
"components": [
{"id": "root-b", "component": {"Card": {"child": "child-b"}}},
{"id": "child-b", "component": {"Text": {"text": "Hello B"}}},
],
}
},
]
# Should not raise - each surface has its own root
catalog_0_8.validator.validate(payload)

def test_validate_multi_surface_missing_root_v08(self, catalog_0_8):
"""Tests that missing root in one surface still fails validation."""
payload = [
{"beginRendering": {"surfaceId": "surface-a", "root": "root-a"}},
{"beginRendering": {"surfaceId": "surface-b", "root": "root-b"}},
{
"surfaceUpdate": {
"surfaceId": "surface-a",
"components": [
{"id": "root-a", "component": {"Text": {"text": "Hello A"}}},
],
}
},
{
"surfaceUpdate": {
"surfaceId": "surface-b",
"components": [
# Missing root-b, only has a non-root component
{"id": "not-root-b", "component": {"Text": {"text": "Hello B"}}},
],
}
},
]
with pytest.raises(ValueError, match="Missing root component.*root-b"):
catalog_0_8.validator.validate(payload)

# --- Incremental update tests ---

def test_incremental_update_no_root_v08(self, catalog_0_8):
"""Incremental update without root component should pass."""
payload = [
{
"surfaceUpdate": {
"surfaceId": "contact-card",
"components": [
{"id": "main_card", "component": {"Card": {"child": "col"}}},
{"id": "col", "component": {"Text": {"text": "Updated"}}},
],
}
},
]
# No beginRendering → incremental update → root check skipped
catalog_0_8.validator.validate(payload)

def test_incremental_update_no_root_v09(self, catalog_0_9):
"""Incremental update without root component should pass (v0.9)."""
payload = [
{
"version": "v0.9",
"updateComponents": {
"surfaceId": "contact-card",
"components": [
{"id": "card1", "component": "Card", "child": "text1"},
{"id": "text1", "component": "Text", "text": "Updated"},
],
},
},
]
# No createSurface → incremental update → root check skipped
catalog_0_9.validator.validate(payload)

def test_incremental_update_orphans_allowed_v08(self, catalog_0_8):
"""Incremental update with 'orphaned' components should pass."""
payload = [
{
"surfaceUpdate": {
"surfaceId": "contact-card",
"components": [
{"id": "text1", "component": {"Text": {"text": "Hello"}}},
{"id": "text2", "component": {"Text": {"text": "World"}}},
],
}
},
]
# These are disconnected but it's an incremental update
catalog_0_8.validator.validate(payload)

def test_incremental_update_self_ref_still_fails(self, test_catalog):
"""Self-references should still be caught in incremental updates."""
if test_catalog.version == VERSION_0_8:
payload = [
{
"surfaceUpdate": {
"surfaceId": "s1",
"components": [
{"id": "card1", "component": {"Card": {"child": "card1"}}},
],
}
},
]
else:
payload = [
{
"version": "v0.9",
"updateComponents": {
"surfaceId": "s1",
"components": [
{"id": "card1", "component": "Card", "child": "card1"},
],
},
},
]
with pytest.raises(ValueError, match="Self-reference detected"):
test_catalog.validator.validate(payload)

def test_incremental_update_cycle_still_fails(self, test_catalog):
"""Cycles should still be caught in incremental updates."""
if test_catalog.version == VERSION_0_8:
payload = [
{
"surfaceUpdate": {
"surfaceId": "s1",
"components": [
{"id": "a", "component": {"Card": {"child": "b"}}},
{"id": "b", "component": {"Card": {"child": "a"}}},
],
}
},
]
else:
payload = [
{
"version": "v0.9",
"updateComponents": {
"surfaceId": "s1",
"components": [
{"id": "a", "component": "Card", "child": "b"},
{"id": "b", "component": "Card", "child": "a"},
],
},
},
]
with pytest.raises(ValueError, match="Circular reference detected"):
test_catalog.validator.validate(payload)

def test_incremental_update_duplicates_still_fail(self, test_catalog):
"""Duplicate IDs should still be caught in incremental updates."""
if test_catalog.version == VERSION_0_8:
payload = [
{
"surfaceUpdate": {
"surfaceId": "s1",
"components": [
{"id": "text1", "component": {"Text": {"text": "A"}}},
{"id": "text1", "component": {"Text": {"text": "B"}}},
],
}
},
]
else:
payload = [
{
"version": "v0.9",
"updateComponents": {
"surfaceId": "s1",
"components": [
{"id": "text1", "component": "Text", "text": "A"},
{"id": "text1", "component": "Text", "text": "B"},
],
},
},
]
with pytest.raises(ValueError, match="Duplicate component ID"):
test_catalog.validator.validate(payload)
22 changes: 11 additions & 11 deletions samples/agent/adk/contact_multiple_surfaces/agent_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,23 +75,23 @@ async def execute(
)
for i, part in enumerate(context.message.parts):
if isinstance(part.root, DataPart):
# Extract client UI capabilities from any DataPart that has them
if (
agent.schema_manager.accepts_inline_catalogs
and "metadata" in part.root.data
and "a2uiClientCapabilities" in part.root.data["metadata"]
):
logger.info(f" Part {i}: Found 'a2uiClientCapabilities' in DataPart.")
client_ui_capabilities = part.root.data["metadata"][
"a2uiClientCapabilities"
]

if "userAction" in part.root.data:
logger.info(f" Part {i}: Found a2ui UI ClientEvent payload.")
ui_event_part = part.root.data["userAction"]
elif "request" in part.root.data:
logger.info(f" Part {i}: Found 'request' in DataPart.")
query = part.root.data["request"]

# Check for inline catalog
if (
agent.schema_manager.accepts_inline_catalogs
and "metadata" in part.root.data
and "a2uiClientCapabilities" in part.root.data["metadata"]
):
logger.info(f" Part {i}: Found 'a2uiClientCapabilities' in DataPart.")
client_ui_capabilities = part.root.data["metadata"][
"a2uiClientCapabilities"
]
else:
logger.info(f" Part {i}: DataPart (data: {part.root.data})")
elif isinstance(part.root, TextPart):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ def get_text_prompt() -> str:

client_ui_capabilities_str = (
'{"inlineCatalogs":[{"catalogId": "inline_catalog",'
' "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"}}}}}}]}'
' "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"}}}}}}]}'
)
Comment on lines 89 to 92
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This long, single-line JSON string is difficult to read and maintain. To improve readability and reduce the chance of syntax errors, consider defining this structure as a Python dictionary and then serializing it to a JSON string using json.dumps.

  client_ui_capabilities_dict = {
      "inlineCatalogs": [{
          "catalogId": "inline_catalog",
          "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"}}
                  }
              }
          }
      }]
  }
  client_ui_capabilities_str = json.dumps(client_ui_capabilities_dict)

client_ui_capabilities = json.loads(client_ui_capabilities_str)
inline_catalog = schema_manager.get_selected_catalog(
Expand Down
Loading