Skip to content

Commit 5b088e8

Browse files
authored
feat: Add a A2uiValidator to validate loaded examples (a2ui-project#557)
1 parent 7c454a0 commit 5b088e8

11 files changed

Lines changed: 1339 additions & 22 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]

a2a_agents/python/a2ui_agent/src/a2ui/inference/inference_strategy.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ def generate_system_prompt(
2828
allowed_components: List[str] = [],
2929
include_schema: bool = False,
3030
include_examples: bool = False,
31+
validate_examples: bool = False,
3132
) -> str:
3233
"""
3334
Generates a system prompt for all LLM requests.
@@ -40,6 +41,7 @@ def generate_system_prompt(
4041
allowed_components: List of allowed components.
4142
include_schema: Whether to include the schema.
4243
include_examples: Whether to include examples.
44+
validate_examples: Whether to validate examples.
4345
4446
Returns:
4547
The system prompt.

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

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,15 @@
1616
import json
1717
import logging
1818
import os
19-
from dataclasses import dataclass, replace
20-
from typing import Any, Dict, List, Optional
19+
from dataclasses import dataclass, field, replace
20+
from typing import Any, Dict, List, Optional, TYPE_CHECKING
2121

2222
from .constants import CATALOG_COMPONENTS_KEY, CATALOG_ID_KEY
2323
from referencing import Registry, Resource
2424

25+
if TYPE_CHECKING:
26+
from .validator import A2uiValidator
27+
2528

2629
@dataclass
2730
class CustomCatalogConfig:
@@ -56,6 +59,12 @@ def catalog_id(self) -> str:
5659
raise ValueError(f"Catalog '{self.name}' missing catalogId")
5760
return self.catalog_schema[CATALOG_ID_KEY]
5861

62+
@property
63+
def validator(self) -> "A2uiValidator":
64+
from .validator import A2uiValidator
65+
66+
return A2uiValidator(self)
67+
5968
def with_pruned_components(self, allowed_components: List[str]) -> "A2uiCatalog":
6069
"""Returns a new catalog with only allowed components.
6170
@@ -123,7 +132,7 @@ def render_as_llm_instructions(self) -> str:
123132

124133
return "\n\n".join(all_schemas)
125134

126-
def load_examples(self, path: Optional[str]) -> str:
135+
def load_examples(self, path: Optional[str], validate: bool = False) -> str:
127136
"""Loads and validates examples from a directory."""
128137
if not path or not os.path.isdir(path):
129138
if path:
@@ -138,6 +147,8 @@ def load_examples(self, path: Optional[str]) -> str:
138147
try:
139148
with open(full_path, "r", encoding="utf-8") as f:
140149
content = f.read()
150+
if validate and not self._validate_example(full_path, basename, content):
151+
continue
141152
merged_examples.append(
142153
f"---BEGIN {basename}---\n{content}\n---END {basename}---"
143154
)
@@ -253,3 +264,12 @@ def merge_into(target: Dict[str, Any], source: Dict[str, Any]):
253264
target["oneOf"] = new_one_of
254265

255266
return result
267+
268+
def _validate_example(self, full_path: str, basename: str, content: str) -> bool:
269+
try:
270+
json_data = json.loads(content)
271+
self.validator.validate(json_data)
272+
except Exception as e:
273+
logging.warning(f"Failed to validate example {full_path}: {e}")
274+
return False
275+
return True

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
CATALOG_SCHEMA_KEY = "catalog"
2121
CATALOG_COMPONENTS_KEY = "components"
2222
CATALOG_ID_KEY = "catalogId"
23+
CATALOG_STYLES_KEY = "styles"
2324

2425
BASE_SCHEMA_URL = "https://a2ui.org/"
2526
BASIC_CATALOG_NAME = "basic"

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -283,10 +283,12 @@ def get_effective_catalog(
283283
pruned_catalog = catalog.with_pruned_components(allowed_components)
284284
return pruned_catalog
285285

286-
def load_examples(self, catalog: A2uiCatalog) -> str:
286+
def load_examples(self, catalog: A2uiCatalog, validate: bool = False) -> str:
287287
"""Loads examples for a catalog."""
288288
if catalog.catalog_id in self._catalog_example_paths:
289-
return catalog.load_examples(self._catalog_example_paths[catalog.catalog_id])
289+
return catalog.load_examples(
290+
self._catalog_example_paths[catalog.catalog_id], validate=validate
291+
)
290292
return ""
291293

292294
def generate_system_prompt(
@@ -298,6 +300,7 @@ def generate_system_prompt(
298300
allowed_components: List[str] = [],
299301
include_schema: bool = False,
300302
include_examples: bool = False,
303+
validate_examples: bool = False,
301304
) -> str:
302305
"""Assembles the final system instruction for the LLM."""
303306
parts = [role_description]
@@ -314,7 +317,7 @@ def generate_system_prompt(
314317
parts.append(final_catalog.render_as_llm_instructions())
315318

316319
if include_examples:
317-
examples_str = self.load_examples(final_catalog)
320+
examples_str = self.load_examples(final_catalog, validate=validate_examples)
318321
if examples_str:
319322
parts.append(f"### Examples:\n{examples_str}")
320323

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
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 copy
16+
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple
17+
18+
from jsonschema import Draft202012Validator
19+
20+
if TYPE_CHECKING:
21+
from .catalog import A2uiCatalog
22+
23+
from .constants import (
24+
BASE_SCHEMA_URL,
25+
CATALOG_COMPONENTS_KEY,
26+
CATALOG_ID_KEY,
27+
CATALOG_STYLES_KEY,
28+
)
29+
30+
31+
def _inject_additional_properties(
32+
schema: Dict[str, Any],
33+
source_properties: Dict[str, Any],
34+
mapping: Dict[str, str] = None,
35+
) -> Tuple[Dict[str, Any], Set[str]]:
36+
"""
37+
Recursively injects properties from source_properties into nodes with additionalProperties=True and sets additionalProperties=False.
38+
39+
Args:
40+
schema: The target schema to traverse and patch.
41+
source_properties: A dictionary of top-level property groups (e.g., "components", "styles") from the source schema.
42+
43+
Returns:
44+
A tuple containing:
45+
- The patched schema.
46+
- A set of keys from source_properties that were injected.
47+
"""
48+
injected_keys = set()
49+
50+
def recursive_inject(obj):
51+
if isinstance(obj, dict):
52+
new_obj = {}
53+
for k, v in obj.items():
54+
# If this node has additionalProperties=True, we inject the source properties
55+
if isinstance(v, dict) and v.get("additionalProperties") is True:
56+
if k in source_properties:
57+
injected_keys.add(k)
58+
new_node = dict(v)
59+
new_node["additionalProperties"] = False
60+
new_node["properties"] = {
61+
**new_node.get("properties", {}),
62+
**source_properties[k],
63+
}
64+
new_obj[k] = new_node
65+
else: # No matching source group, keep as is but recurse children
66+
new_obj[k] = recursive_inject(v)
67+
else: # Not a node with additionalProperties, recurse children
68+
new_obj[k] = recursive_inject(v)
69+
return new_obj
70+
elif isinstance(obj, list):
71+
return [recursive_inject(i) for i in obj]
72+
return obj
73+
74+
return recursive_inject(schema), injected_keys
75+
76+
77+
# LLM is instructed to generate a list of messages, so we wrap the bundled schema in an array.
78+
def _wrap_main_schema(schema: Dict[str, Any]) -> Dict[str, Any]:
79+
return {"type": "array", "items": schema}
80+
81+
82+
class A2uiValidator:
83+
"""Validator for A2UI messages."""
84+
85+
def __init__(self, catalog: "A2uiCatalog"):
86+
self._catalog = catalog
87+
self._validator = self._build_validator()
88+
89+
def _build_validator(self) -> Draft202012Validator:
90+
"""Builds a validator for the A2UI schema."""
91+
92+
if self._catalog.version == "0.8":
93+
return self._build_0_8_validator()
94+
return self._build_0_9_validator()
95+
96+
def _bundle_0_8_schemas(self) -> Dict[str, Any]:
97+
if not self._catalog.s2c_schema:
98+
return {}
99+
100+
bundled = copy.deepcopy(self._catalog.s2c_schema)
101+
102+
# Prepare catalog components and styles for injection
103+
source_properties = {}
104+
catalog_schema = self._catalog.catalog_schema
105+
if catalog_schema:
106+
if CATALOG_COMPONENTS_KEY in catalog_schema:
107+
# Special mapping for v0.8: "components" -> "component"
108+
source_properties["component"] = catalog_schema[CATALOG_COMPONENTS_KEY]
109+
if CATALOG_STYLES_KEY in catalog_schema:
110+
source_properties[CATALOG_STYLES_KEY] = catalog_schema[CATALOG_STYLES_KEY]
111+
112+
bundled, _ = _inject_additional_properties(bundled, source_properties)
113+
return bundled
114+
115+
def _build_0_8_validator(self) -> Draft202012Validator:
116+
"""Builds a validator for the A2UI schema version 0.8."""
117+
bundled_schema = self._bundle_0_8_schemas()
118+
full_schema = _wrap_main_schema(bundled_schema)
119+
return Draft202012Validator(full_schema)
120+
121+
def _build_0_9_validator(self) -> Draft202012Validator:
122+
"""Builds a validator for the A2UI schema version 0.9+."""
123+
full_schema = _wrap_main_schema(self._catalog.s2c_schema)
124+
125+
from referencing import Registry, Resource
126+
127+
# v0.9 schemas (e.g. server_to_client.json) use relative references like
128+
# 'catalog.json#/$defs/anyComponent'. Since server_to_client.json has
129+
# $id: https://a2ui.org/specification/v0_9/server_to_client.json,
130+
# these resolve to https://a2ui.org/specification/v0_9/catalog.json.
131+
# We must register them using these absolute URIs.
132+
base_uri = self._catalog.s2c_schema.get("$id", BASE_SCHEMA_URL)
133+
import os
134+
135+
def get_sibling_uri(uri, filename):
136+
return os.path.join(os.path.dirname(uri), filename)
137+
138+
catalog_uri = get_sibling_uri(base_uri, "catalog.json")
139+
common_types_uri = get_sibling_uri(base_uri, "common_types.json")
140+
141+
resources = [
142+
(
143+
common_types_uri,
144+
Resource.from_contents(self._catalog.common_types_schema),
145+
),
146+
(
147+
catalog_uri,
148+
Resource.from_contents(self._catalog.catalog_schema),
149+
),
150+
# Fallbacks for robustness
151+
("catalog.json", Resource.from_contents(self._catalog.catalog_schema)),
152+
(
153+
"common_types.json",
154+
Resource.from_contents(self._catalog.common_types_schema),
155+
),
156+
]
157+
# Also register the catalog ID if it's different from the catalog URI
158+
if self._catalog.catalog_id and self._catalog.catalog_id != catalog_uri:
159+
resources.append((
160+
self._catalog.catalog_id,
161+
Resource.from_contents(self._catalog.catalog_schema),
162+
))
163+
164+
registry = Registry().with_resources(resources)
165+
validator_schema = copy.deepcopy(full_schema)
166+
validator_schema["$schema"] = "https://json-schema.org/draft/2020-12/schema"
167+
168+
return Draft202012Validator(validator_schema, registry=registry)
169+
170+
def validate(self, message: Dict[str, Any]) -> None:
171+
"""Validates an A2UI message against the schema."""
172+
error = next(self._validator.iter_errors(message), None)
173+
if error is not None:
174+
msg = f"Validation failed: {error.message}"
175+
if error.context:
176+
msg += "\nContext failures:"
177+
for sub_error in error.context:
178+
msg += f"\n - {sub_error.message}"
179+
raise ValueError(msg)

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ def generate_system_prompt(
2727
allowed_components: List[str] = [],
2828
include_schema: bool = False,
2929
include_examples: bool = False,
30+
validate_examples: bool = False,
3031
) -> str:
3132
# TODO: Implementation logic for Template Manager
3233
raise NotImplementedError("This method is not yet implemented.")

a2a_agents/python/a2ui_agent/tests/inference/test_catalog.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,12 @@ def test_catalog_id_missing_raises_error():
4949
def test_load_examples(tmp_path):
5050
example_dir = tmp_path / "examples"
5151
example_dir.mkdir()
52-
(example_dir / "example1.json").write_text('{"foo": "bar"}')
53-
(example_dir / "example2.json").write_text('{"baz": "qux"}')
52+
(example_dir / "example1.json").write_text(
53+
'[{"beginRendering": {"surfaceId": "id"}}]'
54+
)
55+
(example_dir / "example2.json").write_text(
56+
'[{"beginRendering": {"surfaceId": "id"}}]'
57+
)
5458
(example_dir / "ignored.txt").write_text("should not be loaded")
5559

5660
catalog = A2uiCatalog(
@@ -63,9 +67,9 @@ def test_load_examples(tmp_path):
6367

6468
examples_str = catalog.load_examples(str(example_dir))
6569
assert "---BEGIN example1---" in examples_str
66-
assert '{"foo": "bar"}' in examples_str
70+
assert '[{"beginRendering": {"surfaceId": "id"}}]' in examples_str
6771
assert "---BEGIN example2---" in examples_str
68-
assert '{"baz": "qux"}' in examples_str
72+
assert '[{"beginRendering": {"surfaceId": "id"}}]' in examples_str
6973
assert "ignored" not in examples_str
7074

7175

a2a_agents/python/a2ui_agent/tests/inference/test_schema_manager.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ def joinpath_side_effect(path):
218218
return_value="---BEGIN example1---\n{}\n---END example1---",
219219
):
220220
prompt = manager.generate_system_prompt("Role description", include_examples=True)
221-
assert "### Examples:" in prompt
221+
assert "### Examples" in prompt
222222
assert "example1" in prompt
223223

224224
# Test without examples

0 commit comments

Comments
 (0)