Skip to content

Commit 283be8d

Browse files
committed
feat: Introduce schema bundling in A2uiSchemaManager and add a validator
1 parent c320807 commit 283be8d

6 files changed

Lines changed: 1139 additions & 13 deletions

File tree

a2a_agents/python/a2ui_agent/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ dependencies = [
1212
]
1313

1414
[build-system]
15-
requires = ["hatchling"]
15+
requires = ["hatchling", "jsonschema"]
1616
build-backend = "hatchling.build"
1717

1818
[tool.hatch.build.targets.wheel]
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
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+
from typing import Any, Dict
16+
import jsonschema
17+
18+
19+
class A2uiValidator:
20+
"""
21+
Validates JSON instances against a bundled A2UI schema.
22+
"""
23+
24+
def __init__(self, schema: Dict[str, Any]):
25+
self._validator = jsonschema.validators.validator_for(schema)(schema)
26+
27+
def validate(self, instance: Any) -> None:
28+
"""
29+
Validates the given instance against the bundled schema.
30+
Raises jsonschema.exceptions.ValidationError if validation fails.
31+
"""
32+
self._validator.validate(instance)

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

Lines changed: 181 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@
1717
import logging
1818
import os
1919
import importlib.resources
20-
from typing import List, Dict, Any
21-
from .loader import A2uiSchemaLoader, PackageLoader, FileSystemLoader
20+
from typing import List, Dict, Any, Tuple, Set
2221
from ..inference_strategy import InferenceStrategy
22+
from .loader import A2uiSchemaLoader, PackageLoader, FileSystemLoader
23+
from a2ui.core.validator import A2uiValidator
2324

2425
# Helper constants for schema management shared by build hook and runtime
2526

@@ -30,6 +31,7 @@
3031
COMMON_TYPES_SCHEMA_KEY = "common_types"
3132
CATALOG_SCHEMA_KEY = "catalog"
3233
CATALOG_COMPONENTS_KEY = "components"
34+
CATALOG_STYLES_KEY = "styles"
3335

3436
SPEC_VERSION_MAP = {
3537
"0.8": {
@@ -55,6 +57,16 @@ def __init__(self, version: str, custom_catalog_path: str = None):
5557
self.catalog_schema = None
5658
self.common_types_schema = None
5759
self._load_schemas(version, custom_catalog_path)
60+
self._bundled_schema = self.bundle_schemas()
61+
self._validator = A2uiValidator(self._bundled_schema)
62+
63+
@property
64+
def bundled_schema(self):
65+
return self._bundled_schema
66+
67+
@property
68+
def validator(self):
69+
return self._validator
5870

5971
def _load_schemas(self, version: str, custom_catalog_path: str = None):
6072
"""
@@ -220,6 +232,173 @@ def generate_system_prompt(
220232

221233
return "\n\n".join(parts)
222234

235+
def bundle_schemas(self) -> Dict[str, Any]:
236+
"""
237+
Bundles the loaded schemas into a single self-contained schema.
238+
Returns:
239+
A dictionary representing the bundled schema.
240+
"""
241+
bundled = []
242+
if self.version == "0.8":
243+
bundled = self._bundle_0_8(
244+
self.server_to_client_schema,
245+
self.catalog_schema,
246+
self.common_types_schema,
247+
)
248+
else:
249+
# Default to 0.9+ behavior (using $defs)
250+
bundled = self._bundle_0_9(
251+
self.server_to_client_schema,
252+
self.catalog_schema,
253+
self.common_types_schema,
254+
)
255+
# LLM is instructed to generate a list of messages, so we wrap the bundled schema in an array.
256+
return {
257+
"type": "array",
258+
"items": bundled,
259+
}
260+
261+
def _inject_additional_properties(
262+
self,
263+
schema: Dict[str, Any],
264+
source_properties: Dict[str, Any],
265+
mapping: Dict[str, str] = None,
266+
) -> Tuple[Dict[str, Any], Set[str]]:
267+
"""
268+
Recursively injects properties from source_properties into nodes with additionalProperties=True.
269+
Args:
270+
schema: The target schema to traverse and patch.
271+
source_properties: A dictionary of top-level property groups (e.g., "components", "styles") from the source schema.
272+
Returns:
273+
A tuple containing:
274+
- The patched schema.
275+
- A set of keys from source_properties that were injected.
276+
"""
277+
injected_keys = set()
278+
279+
def recursive_inject(obj):
280+
if isinstance(obj, dict):
281+
new_obj = {}
282+
for k, v in obj.items():
283+
if isinstance(v, dict) and v.get("additionalProperties") is True:
284+
if k in source_properties:
285+
injected_keys.add(k)
286+
new_node = dict(v)
287+
new_node["additionalProperties"] = False
288+
new_node["properties"] = {
289+
**new_node.get("properties", {}),
290+
**source_properties[k],
291+
}
292+
new_obj[k] = new_node
293+
else: # No matching source group, keep as is but recurse children
294+
new_obj[k] = recursive_inject(v)
295+
else: # Not a node with additionalProperties, recurse children
296+
new_obj[k] = recursive_inject(v)
297+
return new_obj
298+
elif isinstance(obj, list):
299+
return [recursive_inject(i) for i in obj]
300+
return obj
301+
302+
return recursive_inject(schema), injected_keys
303+
304+
def _bundle_0_9(
305+
self,
306+
server_to_client: Dict[str, Any],
307+
catalog: Dict[str, Any],
308+
common: Dict[str, Any] = None,
309+
) -> Dict[str, Any]:
310+
if not server_to_client:
311+
return {}
312+
313+
bundled = copy.deepcopy(server_to_client)
314+
315+
# Collect source properties and merge schemas
316+
source_properties = {}
317+
schemas_to_merge = []
318+
# merged in order so catalog overrides common.
319+
if common:
320+
schemas_to_merge.append(common)
321+
if catalog:
322+
schemas_to_merge.append(catalog)
323+
324+
if "$defs" not in bundled:
325+
bundled["$defs"] = {}
326+
327+
for schema in schemas_to_merge:
328+
source_properties.update(schema)
329+
330+
# Merge $defs
331+
if "$defs" in schema:
332+
bundled["$defs"].update(schema["$defs"])
333+
334+
# Merge other top-level properties
335+
for k, v in schema.items():
336+
if k not in ["$schema", "title", "$id", "description", "$defs"]:
337+
bundled[k] = v
338+
339+
bundled["$id"] = "https://a2ui.dev/specification/0.9/server_to_client_bundled.json"
340+
bundled["title"] = "Bundled A2UI Message Schema"
341+
bundled["description"] = (
342+
"A self-contained bundled schema including server messages, catalog components,"
343+
" and common types."
344+
)
345+
346+
bundled, injected_keys = self._inject_additional_properties(
347+
bundled, source_properties
348+
)
349+
350+
# Cleanup injected keys from bundled root to avoid duplication
351+
for k in injected_keys:
352+
if k in bundled:
353+
del bundled[k]
354+
355+
# Recursively strip external file references
356+
def rewrite_refs(obj):
357+
if isinstance(obj, dict):
358+
new_obj = {}
359+
for k, v in obj.items():
360+
if k == "$ref" and isinstance(v, str):
361+
if ".json" in v:
362+
ref_parts = v.split(".json")
363+
fragment = ref_parts[-1]
364+
if not fragment:
365+
fragment = "#"
366+
new_obj[k] = fragment
367+
else:
368+
new_obj[k] = v
369+
else:
370+
new_obj[k] = rewrite_refs(v)
371+
return new_obj
372+
elif isinstance(obj, list):
373+
return [rewrite_refs(i) for i in obj]
374+
return obj
375+
376+
return rewrite_refs(bundled)
377+
378+
def _bundle_0_8(
379+
self,
380+
server_to_client: Dict[str, Any],
381+
catalog: Dict[str, Any],
382+
common: Dict[str, Any] = None,
383+
) -> Dict[str, Any]:
384+
if not server_to_client:
385+
return {}
386+
387+
bundled = copy.deepcopy(server_to_client)
388+
389+
# Prepare catalog components and styles for injection
390+
source_properties = {}
391+
if catalog:
392+
if CATALOG_COMPONENTS_KEY in catalog:
393+
# Special mapping for v0.8: "components" -> "component"
394+
source_properties["component"] = catalog[CATALOG_COMPONENTS_KEY]
395+
if CATALOG_STYLES_KEY in catalog:
396+
source_properties[CATALOG_STYLES_KEY] = catalog[CATALOG_STYLES_KEY]
397+
398+
bundled, _ = self._inject_additional_properties(bundled, source_properties)
399+
400+
return bundled
401+
223402

224403
def find_repo_root(start_path: str) -> str | None:
225404
"""Finds the repository root by looking for the 'specification' directory."""
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
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+
import jsonschema
17+
from a2ui.core.validator import A2uiValidator
18+
19+
20+
@pytest.fixture
21+
def mock_bundled_schema():
22+
return {
23+
"type": "array",
24+
"items": {
25+
"$schema": "https://json-schema.org/draft/2020-12/schema",
26+
"type": "object",
27+
"properties": {"foo": {"type": "string"}},
28+
"required": ["foo"],
29+
},
30+
}
31+
32+
33+
def test_validation_success(mock_bundled_schema):
34+
validator = A2uiValidator(mock_bundled_schema)
35+
instance = [{"foo": "bar"}]
36+
validator.validate(instance)
37+
38+
39+
def test_validation_failure(mock_bundled_schema):
40+
validator = A2uiValidator(mock_bundled_schema)
41+
instance = [{"foo": 123}] # Wrong type
42+
43+
with pytest.raises(jsonschema.exceptions.ValidationError):
44+
validator.validate(instance)

0 commit comments

Comments
 (0)