Skip to content

Commit 364307e

Browse files
authored
Update contact multiple surfaces sample (#569)
* feat: Add schema modifiers to A2uiSchemaManager Introduces a `schema_modifiers` parameter to A2uiSchemaManager, allowing custom callable hooks to transform schemas after loading. This enables flexible schema customization, such as relaxing strict validation constraints during testing. * Update contact_multiple_surfaces sample It updates the sample to use the A2uiSchemaManager from the a2ui-agent python SDK. Tested: - [x] The `contact` lit client successfully connected to the `contact_multiple_surfaces` agent and rendered the response correctly.
1 parent 53b8de4 commit 364307e

File tree

11 files changed

+290
-1033
lines changed

11 files changed

+290
-1033
lines changed
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
16+
def remove_strict_validation(schema):
17+
if isinstance(schema, dict):
18+
new_schema = {k: remove_strict_validation(v) for k, v in schema.items()}
19+
if (
20+
'additionalProperties' in new_schema
21+
and new_schema['additionalProperties'] is False
22+
):
23+
del new_schema['additionalProperties']
24+
return new_schema
25+
elif isinstance(schema, list):
26+
return [remove_strict_validation(item) for item in schema]
27+
return schema

a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/manager.py

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import logging
1818
import os
1919
import importlib.resources
20-
from typing import List, Dict, Any, Optional
20+
from typing import List, Dict, Any, Optional, Callable
2121
from dataclasses import dataclass, field
2222
from .loader import A2uiSchemaLoader, PackageLoader, FileSystemLoader
2323
from ..inference_strategy import InferenceStrategy
@@ -122,6 +122,7 @@ def __init__(
122122
custom_catalogs: Optional[List[CustomCatalogConfig]] = None,
123123
exclude_basic_catalog: bool = False,
124124
accepts_inline_catalogs: bool = False,
125+
schema_modifiers: List[Callable[[Dict[str, Any]], Dict[str, Any]]] = None,
125126
):
126127
self._version = version
127128
self._exclude_basic_catalog = exclude_basic_catalog
@@ -132,6 +133,7 @@ def __init__(
132133
self._supported_catalogs: Dict[str, A2uiCatalog] = {}
133134
self._catalog_example_paths: Dict[str, str] = {}
134135
self._basic_catalog = None
136+
self._schema_modifiers = schema_modifiers
135137
self._load_schemas(version, custom_catalogs, basic_examples_path)
136138

137139
@property
@@ -142,6 +144,12 @@ def accepts_inline_catalogs(self) -> bool:
142144
def supported_catalogs(self) -> Dict[str, A2uiCatalog]:
143145
return self._supported_catalogs
144146

147+
def _apply_modifiers(self, schema: Dict[str, Any]) -> Dict[str, Any]:
148+
if self._schema_modifiers:
149+
for modifier in self._schema_modifiers:
150+
schema = modifier(schema)
151+
return schema
152+
145153
def _load_schemas(
146154
self,
147155
version: str,
@@ -156,13 +164,17 @@ def _load_schemas(
156164
)
157165

158166
# Load server-to-client and common types schemas
159-
self._server_to_client_schema = _load_basic_component(
160-
version, SERVER_TO_CLIENT_SCHEMA_KEY
167+
self._server_to_client_schema = self._apply_modifiers(
168+
_load_basic_component(version, SERVER_TO_CLIENT_SCHEMA_KEY)
169+
)
170+
self._common_types_schema = self._apply_modifiers(
171+
_load_basic_component(version, COMMON_TYPES_SCHEMA_KEY)
161172
)
162-
self._common_types_schema = _load_basic_component(version, COMMON_TYPES_SCHEMA_KEY)
163173

164174
# Process basic catalog
165-
basic_catalog_schema = _load_basic_component(version, CATALOG_SCHEMA_KEY)
175+
basic_catalog_schema = self._apply_modifiers(
176+
_load_basic_component(version, CATALOG_SCHEMA_KEY)
177+
)
166178
if not basic_catalog_schema:
167179
basic_catalog_schema = {}
168180

@@ -192,14 +204,16 @@ def _load_schemas(
192204
# Process custom catalogs
193205
if custom_catalogs:
194206
for config in custom_catalogs:
195-
custom_catalog_schema = _load_from_path(config.catalog_path)
207+
custom_catalog_schema = self._apply_modifiers(
208+
_load_from_path(config.catalog_path)
209+
)
196210
resolved_catalog_schema = A2uiCatalog.resolve_schema(
197211
basic_catalog_schema, custom_catalog_schema
198212
)
199213
catalog = A2uiCatalog(
200214
version=version,
201215
name=config.name,
202-
catalog_schema=resolved_catalog_schema,
216+
catalog_schema=self._apply_modifiers(resolved_catalog_schema),
203217
s2c_schema=self._server_to_client_schema,
204218
common_types_schema=self._common_types_schema,
205219
)
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import pytest
16+
from unittest.mock import patch
17+
from a2ui.inference.schema.manager import A2uiSchemaManager
18+
from a2ui.inference.schema.common_modifiers import remove_strict_validation
19+
20+
21+
def test_remove_strict_validation():
22+
"""Tests the remove_strict_validation modifier."""
23+
schema = {
24+
"type": "object",
25+
"properties": {
26+
"a": {"type": "string", "additionalProperties": False},
27+
"b": {
28+
"type": "array",
29+
"items": {"type": "object", "additionalProperties": False},
30+
},
31+
},
32+
"additionalProperties": False,
33+
}
34+
35+
modified = remove_strict_validation(schema)
36+
37+
# Check that additionalProperties: False is removed
38+
assert "additionalProperties" not in modified
39+
assert "additionalProperties" not in modified["properties"]["a"]
40+
assert "additionalProperties" not in modified["properties"]["b"]["items"]
41+
42+
# Check that it didn't mutate the original
43+
assert schema["additionalProperties"] is False
44+
assert schema["properties"]["a"]["additionalProperties"] is False
45+
46+
47+
def test_manager_with_modifiers():
48+
"""Tests that A2uiSchemaManager applies modifiers during loading."""
49+
# Mock _load_basic_component to return a simple schema with strict validation
50+
mock_schema = {"type": "object", "additionalProperties": False}
51+
with patch(
52+
"a2ui.inference.schema.manager._load_basic_component", return_value=mock_schema
53+
):
54+
manager = A2uiSchemaManager("0.8", schema_modifiers=[remove_strict_validation])
55+
56+
# Verify that loaded schemas have modifiers applied
57+
assert "additionalProperties" not in manager._server_to_client_schema
58+
assert "additionalProperties" not in manager._common_types_schema
59+
60+
# basic catalog should also be modified
61+
for catalog in manager._supported_catalogs.values():
62+
assert "additionalProperties" not in catalog.catalog_schema
63+
64+
65+
def test_manager_no_modifiers():
66+
"""Tests that A2uiSchemaManager works fine without modifiers."""
67+
mock_schema = {"type": "object", "additionalProperties": False}
68+
with patch(
69+
"a2ui.inference.schema.manager._load_basic_component", return_value=mock_schema
70+
):
71+
manager = A2uiSchemaManager("0.8", schema_modifiers=None)
72+
73+
# Verify that schemas are NOT modified
74+
assert manager._server_to_client_schema["additionalProperties"] is False

a2a_agents/python/a2ui_agent/tests/integration/verify_load_real.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,13 @@
1616

1717
from a2ui.inference.schema.manager import A2uiSchemaManager
1818
from a2ui.inference.schema.constants import CATALOG_COMPONENTS_KEY
19+
from a2ui.inference.schema.common_modifiers import remove_strict_validation
1920

2021

2122
def verify():
2223
print('Verifying A2uiSchemaManager...')
2324
try:
24-
manager = A2uiSchemaManager('0.8')
25+
manager = A2uiSchemaManager('0.8', schema_modifiers=[remove_strict_validation])
2526
catalog = manager.get_effective_catalog()
2627
catalog_components = catalog.catalog_schema[CATALOG_COMPONENTS_KEY]
2728
print(f'Successfully loaded 0.8: {len(catalog_components)} components')
@@ -364,6 +365,13 @@ def verify():
364365
'key': 'imageUrl',
365366
'valueString': 'http://localhost:10003/static/profile2.png',
366367
},
368+
{
369+
'key': 'contacts',
370+
'valueMap': [{
371+
'key': 'contact1',
372+
'valueMap': [{'key': 'name', 'valueString': 'Casey Smith'}],
373+
}],
374+
},
367375
],
368376
}
369377
},
@@ -375,7 +383,7 @@ def verify():
375383
sys.exit(1)
376384

377385
try:
378-
manager = A2uiSchemaManager('0.9')
386+
manager = A2uiSchemaManager('0.9', schema_modifiers=[remove_strict_validation])
379387
catalog = manager.get_effective_catalog()
380388
catalog_components = catalog.catalog_schema[CATALOG_COMPONENTS_KEY]
381389
print(f'Successfully loaded 0.9: {len(catalog_components)} components')
@@ -389,6 +397,7 @@ def verify():
389397
'catalogId': (
390398
'https://a2ui.dev/specification/v0_9/standard_catalog.json'
391399
),
400+
'fakeProperty': 'should be allowed',
392401
},
393402
},
394403
{

samples/agent/adk/contact_multiple_surfaces/__main__.py

Lines changed: 5 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,6 @@
1919
from a2a.server.apps import A2AStarletteApplication
2020
from a2a.server.request_handlers import DefaultRequestHandler
2121
from a2a.server.tasks import InMemoryTaskStore
22-
from a2a.types import AgentCapabilities, AgentCard, AgentSkill
23-
from a2ui.extension.a2ui_extension import get_a2ui_agent_extension
2422
from agent import ContactAgent
2523
from agent_executor import ContactAgentExecutor
2624
from dotenv import load_dotenv
@@ -46,48 +44,22 @@ def main(host, port):
4644
if not os.getenv("GOOGLE_GENAI_USE_VERTEXAI") == "TRUE":
4745
if not os.getenv("GEMINI_API_KEY"):
4846
raise MissingAPIKeyError(
49-
"GEMINI_API_KEY environment variable not set and GOOGLE_GENAI_USE_VERTEXAI"
50-
" is not TRUE."
47+
"GEMINI_API_KEY environment variable not set and"
48+
" GOOGLE_GENAI_USE_VERTEXAI is not TRUE."
5149
)
5250

53-
capabilities = AgentCapabilities(
54-
streaming=True,
55-
extensions=[get_a2ui_agent_extension()],
56-
)
57-
skill = AgentSkill(
58-
id="find_contact",
59-
name="Find Contact Tool",
60-
description=(
61-
"Helps find contact information for colleagues (e.g., email, location,"
62-
" team)."
63-
),
64-
tags=["contact", "directory", "people", "finder"],
65-
examples=["Who is David Chen in marketing?", "Find Sarah Lee from engineering"],
66-
)
67-
6851
base_url = f"http://{host}:{port}"
6952

70-
agent_card = AgentCard(
71-
name="Contact Lookup Agent",
72-
description=(
73-
"This agent helps find contact info for people in your organization."
74-
),
75-
url=base_url, # <-- Use base_url here
76-
version="1.0.0",
77-
default_input_modes=ContactAgent.SUPPORTED_CONTENT_TYPES,
78-
default_output_modes=ContactAgent.SUPPORTED_CONTENT_TYPES,
79-
capabilities=capabilities,
80-
skills=[skill],
81-
)
53+
agent = ContactAgent(base_url=base_url, use_ui=True)
8254

83-
agent_executor = ContactAgentExecutor(base_url=base_url)
55+
agent_executor = ContactAgentExecutor(agent=agent)
8456

8557
request_handler = DefaultRequestHandler(
8658
agent_executor=agent_executor,
8759
task_store=InMemoryTaskStore(),
8860
)
8961
server = A2AStarletteApplication(
90-
agent_card=agent_card, http_handler=request_handler
62+
agent_card=agent.get_agent_card(), http_handler=request_handler
9163
)
9264
import uvicorn
9365

samples/agent/adk/contact_multiple_surfaces/a2ui_examples.py

Lines changed: 0 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
from pathlib import Path
1919

2020
import jsonschema
21-
from a2ui_schema import A2UI_SCHEMA
2221

2322
logger = logging.getLogger(__name__)
2423

@@ -35,77 +34,6 @@
3534
FLOOR_PLAN_FILE = "floor_plan.json"
3635

3736

38-
def load_examples(base_url: str = "http://localhost:10004") -> str:
39-
"""
40-
Loads, validates, and formats the UI examples from JSON files.
41-
42-
Args:
43-
base_url: The base URL to replace placeholder URLs with.
44-
(Currently examples have http://localhost:10004 hardcoded,
45-
but we can make this dynamic if needed).
46-
47-
Returns:
48-
A string containing all formatted examples for the prompt.
49-
"""
50-
51-
# Pre-parse validator
52-
try:
53-
single_msg_schema = json.loads(A2UI_SCHEMA)
54-
# Examples are typically lists of messages
55-
list_schema = {"type": "array", "items": single_msg_schema}
56-
except json.JSONDecodeError:
57-
logger.error("Failed to parse A2UI_SCHEMA for validation")
58-
list_schema = None
59-
60-
examples_dir = Path(os.path.dirname(__file__)) / "examples"
61-
formatted_output = []
62-
63-
for curr_name, filename in EXAMPLE_FILES.items():
64-
file_path = examples_dir / filename
65-
try:
66-
content = file_path.read_text(encoding="utf-8")
67-
68-
# basic replacement if we decide to template the URL in JSON files
69-
# content = content.replace("{{BASE_URL}}", base_url)
70-
71-
# Validation
72-
if list_schema:
73-
try:
74-
data = json.loads(content)
75-
jsonschema.validate(instance=data, schema=list_schema)
76-
except (json.JSONDecodeError, jsonschema.ValidationError) as e:
77-
logger.warning(f"Example {filename} validation failed: {e}")
78-
79-
formatted_output.append(f"---BEGIN {curr_name}---")
80-
# Handle examples that include user/model text
81-
if curr_name == "ORG_CHART_EXAMPLE":
82-
formatted_output.append("User: Show me the org chart for Casey Smith")
83-
formatted_output.append("Model: Here is the organizational chart.")
84-
formatted_output.append("---a2ui_JSON---")
85-
elif curr_name == "MULTI_SURFACE_EXAMPLE":
86-
formatted_output.append("User: Full profile for Casey Smith")
87-
formatted_output.append(
88-
"Model: Here is the full profile including contact details and org chart."
89-
)
90-
formatted_output.append("---a2ui_JSON---")
91-
elif curr_name == "CHART_NODE_CLICK_EXAMPLE":
92-
formatted_output.append(
93-
'User: ACTION: chart_node_click (context: clickedNodeName="John Smith")'
94-
" (from modal)"
95-
)
96-
formatted_output.append("Model: Here is the profile for John Smith.")
97-
formatted_output.append("---a2ui_JSON---")
98-
99-
formatted_output.append(content.strip())
100-
formatted_output.append(f"---END {curr_name}---")
101-
formatted_output.append("") # Newline
102-
103-
except FileNotFoundError:
104-
logger.error(f"Example file not found: {file_path}")
105-
106-
return "\n".join(formatted_output)
107-
108-
10937
def load_floor_plan_example() -> str:
11038
"""Loads the floor plan example specifically."""
11139
examples_dir = Path(os.path.dirname(__file__)) / "examples"

0 commit comments

Comments
 (0)