Skip to content

Commit 5cd5eb1

Browse files
authored
Remove notion of "custom catalogs" from agent SDK (#705)
* 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 * Refactor: extract basic catalog logic into its own package
1 parent fd325e2 commit 5cd5eb1

File tree

27 files changed

+1371
-726
lines changed

27 files changed

+1371
-726
lines changed

a2a_agents/python/a2ui_agent/agent_development.md

Lines changed: 15 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,29 @@ 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
22+
from a2ui.inference.basic_catalog.provider import BasicCatalog
2123

2224
schema_manager = A2uiSchemaManager(
23-
version="0.8",
24-
basic_examples_path="path/to/basic/examples",
25-
custom_catalogs=[
26-
CustomCatalogConfig(
25+
version=VERSION_0_8,
26+
catalogs=[
27+
BasicCatalog.get_config(
28+
version=VERSION_0_8,
29+
examples_path="examples"
30+
),
31+
CatalogConfig.from_path(
2732
name="my_custom_catalog",
2833
catalog_path="path/to/catalog.json",
2934
examples_path="path/to/examples"
30-
)
31-
]
35+
),
36+
],
3237
)
3338
```
3439

3540
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.
41+
- The `catalogs` parameter is optional. If not provided, the schema manager will use the basic catalog maintained by the A2UI team.
42+
- The provided catalogs must be freestanding, i.e. they should not reference any external schemas or components, except for the common types.
3843
- If you have a modular catalog that references other catalogs, refer to [Freestanding Catalogs](../../../docs/catalogs.md#freestanding-catalogs) for more information.
3944

4045
### Step 2: Generate System Prompt

a2a_agents/python/a2ui_agent/pack_specs_hook.py

Lines changed: 60 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -14,41 +14,58 @@
1414

1515
import importlib.util
1616
import os
17+
import sys
1718
import shutil
1819
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
1920

2021

21-
def load_constants(project_root):
22-
"""Loads the shared constants module directly from its path in src/."""
23-
constants_path = os.path.join(
24-
project_root, "src", "a2ui", "inference", "schema", "constants.py"
25-
)
26-
if not os.path.exists(constants_path):
27-
raise RuntimeError(f"Could not find shared constants at {constants_path}")
22+
def load_module(project_root, rel_path, filename, module_name):
23+
"""Loads a module directly from its path in src/."""
24+
path = os.path.join(project_root, "src", rel_path.replace(".", os.sep), filename)
25+
if not os.path.exists(path):
26+
raise RuntimeError(f"Could not find module at {path}")
2827

29-
spec = importlib.util.spec_from_file_location("_constants_load", constants_path)
28+
# Add src to sys.path so absolute imports work
29+
src_path = os.path.abspath(os.path.join(project_root, "src"))
30+
if src_path not in sys.path:
31+
sys.path.insert(0, src_path)
32+
33+
spec = importlib.util.spec_from_file_location(module_name, path)
3034
if spec and spec.loader:
3135
module = importlib.util.module_from_spec(spec)
36+
# Set the package context to allow relative imports if any
37+
module.__package__ = rel_path
38+
sys.modules[module_name] = module
3239
spec.loader.exec_module(module)
3340
return module
34-
raise RuntimeError(f"Could not load shared constants from {constants_path}")
41+
raise RuntimeError(f"Could not load module from {path}")
3542

3643

3744
class PackSpecsBuildHook(BuildHookInterface):
3845

3946
def initialize(self, version, build_data):
4047
project_root = self.root
4148

42-
# Load constants dynamically from src/a2ui/inference/schema/constants.py
43-
a2ui_constants = load_constants(project_root)
49+
# Load constants and utils dynamically from src/
50+
schema_path = "a2ui.inference.schema"
51+
a2ui_constants = load_module(
52+
project_root, schema_path, "constants.py", "_constants_load"
53+
)
54+
a2ui_utils = load_module(project_root, schema_path, "utils.py", "_utils_load")
55+
56+
basic_catalog_constants = load_module(
57+
project_root,
58+
"a2ui.inference.basic_catalog",
59+
"constants.py",
60+
"_basic_catalog_constants_load",
61+
)
4462

4563
spec_version_map = a2ui_constants.SPEC_VERSION_MAP
4664
a2ui_asset_package = a2ui_constants.A2UI_ASSET_PACKAGE
4765
specification_dir = a2ui_constants.SPECIFICATION_DIR
4866

49-
# project root is in a2a_agents/python/a2ui_agent
5067
# Dynamically find repo root by looking for specification_dir
51-
repo_root = a2ui_constants.find_repo_root(project_root)
68+
repo_root = a2ui_utils.find_repo_root(project_root)
5269
if not repo_root:
5370
# Check for PKG-INFO which implies a packaged state (sdist).
5471
# If PKG-INFO is present, trust the bundled assets.
@@ -66,23 +83,40 @@ def initialize(self, version, build_data):
6683
project_root, "src", a2ui_asset_package.replace(".", os.sep)
6784
)
6885

69-
for ver, schema_map in spec_version_map.items():
86+
self._pack_schemas(repo_root, spec_version_map, target_base)
87+
self._pack_basic_catalogs(
88+
repo_root, basic_catalog_constants.BASIC_CATALOG_PATHS, target_base
89+
)
90+
91+
def _pack_schemas(self, repo_root, spec_map, target_base):
92+
for ver, schema_map in spec_map.items():
7093
target_dir = os.path.join(target_base, ver)
7194
os.makedirs(target_dir, exist_ok=True)
7295

7396
for _schema_key, source_rel_path in schema_map.items():
74-
source_path = os.path.join(repo_root, source_rel_path)
97+
self._copy_schema(repo_root, source_rel_path, target_dir)
7598

76-
if not os.path.exists(source_path):
77-
print(
78-
f"WARNING: Source schema file not found at {source_path}. Build"
79-
" might produce incomplete wheel if not running from monorepo"
80-
" root."
81-
)
82-
continue
99+
def _pack_basic_catalogs(self, repo_root, catalog_paths, target_base):
100+
for ver, path_map in catalog_paths.items():
101+
target_dir = os.path.join(target_base, ver)
102+
os.makedirs(target_dir, exist_ok=True)
103+
104+
for _key, source_rel_path in path_map.items():
105+
self._copy_schema(repo_root, source_rel_path, target_dir)
106+
107+
def _copy_schema(self, repo_root, source_rel_path, target_dir):
108+
source_path = os.path.join(repo_root, source_rel_path)
109+
110+
if not os.path.exists(source_path):
111+
print(
112+
f"WARNING: Source schema file not found at {source_path}. Build"
113+
" might produce incomplete wheel if not running from monorepo"
114+
" root."
115+
)
116+
return
83117

84-
filename = os.path.basename(source_path)
85-
dst_file = os.path.join(target_dir, filename)
118+
filename = os.path.basename(source_path)
119+
dst_file = os.path.join(target_dir, filename)
86120

87-
print(f"Copying {source_path} -> {dst_file}")
88-
shutil.copy2(source_path, dst_file)
121+
print(f"Copying {source_path} -> {dst_file}")
122+
shutil.copy2(source_path, dst_file)

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:
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
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 .provider import BasicCatalog
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
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 ..schema.constants import CATALOG_SCHEMA_KEY, VERSION_0_8, VERSION_0_9
16+
17+
BASIC_CATALOG_NAME = "basic"
18+
19+
# Maps version to the relative path of the basic catalog schema in the source repo
20+
BASIC_CATALOG_PATHS = {
21+
VERSION_0_8: {
22+
CATALOG_SCHEMA_KEY: "specification/v0_8/json/standard_catalog_definition.json"
23+
},
24+
VERSION_0_9: {CATALOG_SCHEMA_KEY: "specification/v0_9/json/basic_catalog.json"},
25+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
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, Optional
16+
17+
from ..schema.catalog import CatalogConfig
18+
from ..schema.catalog_provider import A2uiCatalogProvider
19+
from ..schema.utils import load_from_bundled_resource
20+
from ..schema.constants import BASE_SCHEMA_URL, CATALOG_ID_KEY, CATALOG_SCHEMA_KEY
21+
from .constants import BASIC_CATALOG_NAME, BASIC_CATALOG_PATHS
22+
23+
24+
class BundledCatalogProvider(A2uiCatalogProvider):
25+
"""Loads schemas from bundled package resources with fallbacks."""
26+
27+
def __init__(self, version: str):
28+
self.version = version
29+
30+
def load(self) -> Dict[str, Any]:
31+
# Use load_from_bundled_resource but with the specialized basic catalog paths
32+
resource = load_from_bundled_resource(
33+
self.version, CATALOG_SCHEMA_KEY, BASIC_CATALOG_PATHS
34+
)
35+
36+
# Post-load processing for catalogs
37+
if CATALOG_ID_KEY not in resource:
38+
spec_map = BASIC_CATALOG_PATHS.get(self.version)
39+
if spec_map and CATALOG_SCHEMA_KEY in spec_map:
40+
rel_path = spec_map[CATALOG_SCHEMA_KEY]
41+
# Strip the `json/` part from the catalog file path for the ID.
42+
catalog_file = rel_path.replace("/json/", "/")
43+
resource[CATALOG_ID_KEY] = BASE_SCHEMA_URL + catalog_file
44+
45+
if "$schema" not in resource:
46+
resource["$schema"] = "https://json-schema.org/draft/2020-12/schema"
47+
48+
return resource
49+
50+
51+
class BasicCatalog:
52+
"""Helper for accessing the basic A2UI catalog."""
53+
54+
@staticmethod
55+
def get_config(version: str, examples_path: Optional[str] = None) -> CatalogConfig:
56+
"""Returns a CatalogConfig for the basic bundled catalog."""
57+
return CatalogConfig(
58+
name=BASIC_CATALOG_NAME,
59+
provider=BundledCatalogProvider(version),
60+
examples_path=examples_path,
61+
)

0 commit comments

Comments
 (0)