Skip to content

Commit 6e6bd97

Browse files
committed
Remove notion of "custom catalogs" from agent SDK
The basic catalog maintained by the A2UI team has no difference from third-party catalogs. This PR removes the notion of custom catalogs. Each catalog provided at runtime should be independent and immutable. At build time, catalogs can refer to components from other catalogs. They need to be bundled into a free-standing one using the `tools/build_catalog/build_catalog.py` script. Fixes #650
1 parent 5db8030 commit 6e6bd97

File tree

21 files changed

+1189
-691
lines changed

21 files changed

+1189
-691
lines changed

a2a_agents/python/a2ui_agent/agent_development.md

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ This guide explains how to build AI agents that generate A2UI interfaces using t
66

77
The `a2ui_agent` SDK revolves around three main classes:
88

9-
* **`CustomCatalogConfig`**: Defines the metadata for a component catalog (name, schema path, examples path).
9+
* **`CatalogConfig`**: Defines the metadata for a component catalog (name, schema path, examples path).
1010
* **`A2uiCatalog`**: Represents a processed catalog, providing methods for validation and LLM instruction rendering.
1111
* **`A2uiSchemaManager`**: The central coordinator that loads catalogs, manages versioning, and generates system prompts.
1212

@@ -17,24 +17,25 @@ The `a2ui_agent` SDK revolves around three main classes:
1717
The first step in any A2UI-enabled agent is initializing the `A2uiSchemaManager`.
1818

1919
```python
20-
from a2ui.inference.schema.manager import A2uiSchemaManager, CustomCatalogConfig
20+
from a2ui.inference.schema.constants import VERSION_0_8
21+
from a2ui.inference.schema.manager import A2uiSchemaManager, CatalogConfig
2122

2223
schema_manager = A2uiSchemaManager(
23-
version="0.8",
24-
basic_examples_path="path/to/basic/examples",
25-
custom_catalogs=[
26-
CustomCatalogConfig(
24+
version=VERSION_0_8,
25+
catalogs=[
26+
CatalogConfig.bundled(version=VERSION_0_8, examples_path="examples"),
27+
CatalogConfig.from_path(
2728
name="my_custom_catalog",
2829
catalog_path="path/to/catalog.json",
2930
examples_path="path/to/examples"
30-
)
31-
]
31+
),
32+
],
3233
)
3334
```
3435

3536
Notes:
36-
- The `custom_catalogs` parameter is optional. If not provided, the schema manager will use the basic catalog maintained by the A2UI team.
37-
- The provided custom catalog must be freestanding, i.e. it should not reference any external schemas or components, except for the common types.
37+
- The `catalogs` parameter is optional. If not provided, the schema manager will use the basic catalog maintained by the A2UI team.
38+
- The provided catalogs must be freestanding, i.e. they should not reference any external schemas or components, except for the common types.
3839
- If you have a modular catalog that references other catalogs, refer to [Freestanding Catalogs](../../../docs/catalogs.md#freestanding-catalogs) for more information.
3940

4041
### Step 2: Generate System Prompt

a2a_agents/python/a2ui_agent/src/a2ui/extension/a2ui_extension.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ def get_a2ui_agent_extension(
9494
"""Creates the A2UI AgentExtension configuration.
9595
9696
Args:
97-
accepts_inline_catalogs: Whether the agent accepts inline custom catalogs.
97+
accepts_inline_catalogs: Whether the agent accepts inline catalogs.
9898
supported_catalog_ids: All pre-defined catalogs the agent is known to support.
9999
100100
Returns:

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

Lines changed: 37 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -19,22 +19,50 @@
1919
from dataclasses import dataclass, field, replace
2020
from typing import Any, Dict, List, Optional, TYPE_CHECKING
2121

22-
from .constants import CATALOG_COMPONENTS_KEY, CATALOG_ID_KEY
23-
from referencing import Registry, Resource
24-
25-
if TYPE_CHECKING:
26-
from .validator import A2uiValidator
27-
from .payload_fixer import A2uiPayloadFixer
22+
from .catalog_provider import A2uiCatalogProvider, FileSystemCatalogProvider, BundledCatalogProvider
23+
from .constants import CATALOG_COMPONENTS_KEY, CATALOG_ID_KEY, BASIC_CATALOG_NAME
2824

2925

3026
@dataclass
31-
class CustomCatalogConfig:
32-
"""Configuration for a custom component catalog."""
27+
class CatalogConfig:
28+
"""
29+
Configuration for a catalog of components.
30+
31+
A catalog consists of a provider that knows how to load the schema,
32+
and optionally a path to examples.
33+
34+
Attributes:
35+
name: The name of the catalog.
36+
provider: The provider to use to load the catalog schema.
37+
examples_path: The path to the examples directory.
38+
"""
3339

3440
name: str
35-
catalog_path: str
41+
provider: A2uiCatalogProvider
3642
examples_path: Optional[str] = None
3743

44+
@classmethod
45+
def bundled(
46+
cls, version: str, examples_path: Optional[str] = None
47+
) -> "CatalogConfig":
48+
"""Returns a CatalogConfig for the basic bundled catalog."""
49+
return cls(
50+
name=BASIC_CATALOG_NAME,
51+
provider=BundledCatalogProvider(version),
52+
examples_path=examples_path,
53+
)
54+
55+
@classmethod
56+
def from_path(
57+
cls, name: str, catalog_path: str, examples_path: Optional[str] = None
58+
) -> "CatalogConfig":
59+
"""Returns a CatalogConfig that loads from a file path."""
60+
return cls(
61+
name=name,
62+
provider=FileSystemCatalogProvider(catalog_path),
63+
examples_path=examples_path,
64+
)
65+
3866

3967
@dataclass(frozen=True)
4068
class A2uiCatalog:
@@ -165,113 +193,6 @@ def load_examples(self, path: Optional[str], validate: bool = False) -> str:
165193
return ""
166194
return "\n\n".join(merged_examples)
167195

168-
@staticmethod
169-
def resolve_schema(basic: Dict[str, Any], custom: Dict[str, Any]) -> Dict[str, Any]:
170-
"""Resolves references in custom catalog schema against the basic catalog.
171-
172-
Args:
173-
basic: The basic catalog schema.
174-
custom: The custom catalog schema.
175-
176-
Returns:
177-
A new dictionary with references resolved.
178-
"""
179-
result = copy.deepcopy(custom)
180-
181-
# Initialize registry with basic catalog and maybe others from basic's $id
182-
registry = Registry()
183-
if CATALOG_ID_KEY in basic:
184-
basic_resource = Resource.from_contents(basic)
185-
registry = registry.with_resource(basic[CATALOG_ID_KEY], basic_resource)
186-
187-
def resolve_ref(ref_uri: str) -> Any:
188-
try:
189-
resolver = registry.resolver()
190-
resolved = resolver.lookup(ref_uri)
191-
return resolved.contents
192-
except Exception as e:
193-
logging.warning("Could not resolve reference %s: %s", ref_uri, e)
194-
return None
195-
196-
def merge_into(target: Dict[str, Any], source: Dict[str, Any]):
197-
for key, value in source.items():
198-
if key not in target:
199-
target[key] = copy.deepcopy(value)
200-
201-
# Process components
202-
if CATALOG_COMPONENTS_KEY in result:
203-
comp_dict = result[CATALOG_COMPONENTS_KEY]
204-
if "$ref" in comp_dict:
205-
resolved = resolve_ref(comp_dict["$ref"])
206-
if isinstance(resolved, dict):
207-
merge_into(comp_dict, resolved)
208-
del comp_dict["$ref"]
209-
210-
# Process functions
211-
if "functions" in result:
212-
func_dict = result["functions"]
213-
if "$ref" in func_dict:
214-
resolved = resolve_ref(func_dict["$ref"])
215-
if isinstance(resolved, dict):
216-
merge_into(func_dict, resolved)
217-
del func_dict["$ref"]
218-
219-
# Process $defs
220-
if "$defs" in result:
221-
res_defs = result["$defs"]
222-
if "$ref" in res_defs:
223-
resolved = resolve_ref(res_defs["$ref"])
224-
if isinstance(resolved, dict):
225-
merge_into(res_defs, resolved)
226-
del res_defs["$ref"]
227-
228-
for name in ["anyComponent", "anyFunction"]:
229-
if name in res_defs:
230-
target = res_defs[name]
231-
one_of = target.get("oneOf", [])
232-
new_one_of = []
233-
for item in one_of:
234-
if isinstance(item, dict) and "$ref" in item:
235-
ref_uri = item["$ref"]
236-
# Check if it points to basic collector
237-
resolved = resolve_ref(ref_uri)
238-
if isinstance(resolved, dict) and "oneOf" in resolved:
239-
# Merge oneOf items and resolve transitive refs to components/functions
240-
for sub_item in resolved["oneOf"]:
241-
if sub_item not in new_one_of:
242-
new_one_of.append(copy.deepcopy(sub_item))
243-
# Transitive merge: if sub_item is a ref to a component/function
244-
if isinstance(sub_item, dict) and "$ref" in sub_item:
245-
sub_ref = sub_item["$ref"]
246-
if (
247-
sub_ref.startswith("#/components/")
248-
and CATALOG_COMPONENTS_KEY in basic
249-
):
250-
comp_name = sub_ref.split("/")[-1]
251-
if comp_name in basic[CATALOG_COMPONENTS_KEY]:
252-
if CATALOG_COMPONENTS_KEY not in result:
253-
result[CATALOG_COMPONENTS_KEY] = {}
254-
if comp_name not in result[CATALOG_COMPONENTS_KEY]:
255-
result[CATALOG_COMPONENTS_KEY][comp_name] = copy.deepcopy(
256-
basic[CATALOG_COMPONENTS_KEY][comp_name]
257-
)
258-
elif sub_ref.startswith("#/functions/") and "functions" in basic:
259-
func_name = sub_ref.split("/")[-1]
260-
if func_name in basic["functions"]:
261-
if "functions" not in result:
262-
result["functions"] = {}
263-
if func_name not in result["functions"]:
264-
result["functions"][func_name] = copy.deepcopy(
265-
basic["functions"][func_name]
266-
)
267-
else:
268-
new_one_of.append(item)
269-
else:
270-
new_one_of.append(item)
271-
target["oneOf"] = new_one_of
272-
273-
return result
274-
275196
def _validate_example(self, full_path: str, basename: str, content: str) -> bool:
276197
try:
277198
json_data = json.loads(content)
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
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+
"""Module for providing A2UI catalog schemas and resources."""
16+
17+
import json
18+
import logging
19+
import os
20+
import importlib.resources
21+
from abc import ABC, abstractmethod
22+
from json.decoder import JSONDecodeError
23+
from typing import Any, Dict, Optional
24+
25+
from .constants import (
26+
A2UI_ASSET_PACKAGE,
27+
BASE_SCHEMA_URL,
28+
CATALOG_ID_KEY,
29+
CATALOG_SCHEMA_KEY,
30+
SPEC_VERSION_MAP,
31+
find_repo_root,
32+
)
33+
34+
ENCODING = "utf-8"
35+
36+
37+
class A2uiCatalogProvider(ABC):
38+
"""Abstract base class for providing A2UI schemas and catalogs."""
39+
40+
@abstractmethod
41+
def load(self) -> Dict[str, Any]:
42+
"""Loads a schema resource.
43+
44+
Returns:
45+
The loaded schema as a dictionary.
46+
"""
47+
pass
48+
49+
50+
class FileSystemCatalogProvider(A2uiCatalogProvider):
51+
"""Loads schemas from the local filesystem."""
52+
53+
def __init__(self, path: str):
54+
self.path = path
55+
56+
def load(self) -> Dict[str, Any]:
57+
try:
58+
with open(self.path, "r", encoding=ENCODING) as f:
59+
return json.load(f)
60+
except (FileNotFoundError, JSONDecodeError) as e:
61+
raise IOError(f"Could not load schema from {self.path}: {e}") from e
62+
63+
64+
def load_from_bundled_resource(version: str, resource_key: str) -> Dict[str, Any]:
65+
"""Loads a schema resource from bundled package resources."""
66+
spec_map = SPEC_VERSION_MAP.get(version)
67+
if not spec_map:
68+
raise ValueError(f"Unknown A2UI version: {version}")
69+
70+
if resource_key not in spec_map:
71+
return None
72+
73+
rel_path = spec_map[resource_key]
74+
filename = os.path.basename(rel_path)
75+
76+
# 1. Try to load from the bundled package resources.
77+
try:
78+
traversable = importlib.resources.files(A2UI_ASSET_PACKAGE)
79+
traversable = traversable.joinpath(version).joinpath(filename)
80+
with traversable.open("r", encoding=ENCODING) as f:
81+
return json.load(f)
82+
except Exception as e:
83+
logging.debug("Could not load '%s' from package resources: %s", filename, e)
84+
85+
# 2. Fallback to local assets
86+
# This handles cases where assets might be present in src but not installed
87+
try:
88+
potential_path = os.path.abspath(
89+
os.path.join(
90+
os.path.dirname(__file__),
91+
"..",
92+
"assets",
93+
version,
94+
filename,
95+
)
96+
)
97+
if os.path.exists(potential_path):
98+
provider = FileSystemCatalogProvider(potential_path)
99+
return provider.load()
100+
except Exception as e:
101+
logging.debug("Could not load schema '%s' from local assets: %s", filename, e)
102+
103+
# 3. Fallback: Source Repository (specification/...)
104+
# This handles cases where we are running directly from source tree
105+
# And assets are not yet copied to src/a2ui/assets
106+
# manager.py is at a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/manager.py
107+
# Dynamically find repo root by looking for "specification" directory
108+
try:
109+
repo_root = find_repo_root(os.path.dirname(__file__))
110+
if repo_root:
111+
source_path = os.path.join(repo_root, rel_path)
112+
if os.path.exists(source_path):
113+
provider = FileSystemCatalogProvider(source_path)
114+
return provider.load()
115+
except Exception as e:
116+
logging.debug("Could not load schema from source repo: %s", e)
117+
118+
raise IOError(f"Could not load schema {filename} for version {version}")
119+
120+
121+
class BundledCatalogProvider(A2uiCatalogProvider):
122+
"""Loads schemas from bundled package resources with fallbacks."""
123+
124+
def __init__(self, version: str):
125+
self.version = version
126+
127+
def load(self) -> Dict[str, Any]:
128+
129+
resource = load_from_bundled_resource(self.version, CATALOG_SCHEMA_KEY)
130+
131+
# Post-load processing for catalogs
132+
if CATALOG_ID_KEY not in resource:
133+
spec_map = SPEC_VERSION_MAP.get(self.version)
134+
rel_path = spec_map[CATALOG_SCHEMA_KEY]
135+
# Strip the `json/` part from the catalog file path for the ID.
136+
catalog_file = rel_path.replace("/json/", "/")
137+
resource[CATALOG_ID_KEY] = BASE_SCHEMA_URL + catalog_file
138+
139+
if "$schema" not in resource:
140+
resource["$schema"] = "https://json-schema.org/draft/2020-12/schema"
141+
142+
return resource

0 commit comments

Comments
 (0)