Skip to content

Commit 27bb573

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 0548425 commit 27bb573

File tree

16 files changed

+883
-424
lines changed

16 files changed

+883
-424
lines changed

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: 25 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
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
22+
from .constants import CATALOG_COMPONENTS_KEY, CATALOG_ID_KEY, BASIC_CATALOG_NAME
2323
from referencing import Registry, Resource
2424

2525
if TYPE_CHECKING:
@@ -28,13 +28,35 @@
2828

2929

3030
@dataclass
31-
class CustomCatalogConfig:
32-
"""Configuration for a custom component catalog."""
31+
class CatalogConfig:
32+
"""
33+
Configuration for a catalog of components.
34+
35+
The catalog must be free standing and not reference any other catalogs, except
36+
for the common types schema.
37+
38+
If a catalog references other catalogs, the references must be resolved before
39+
loading the catalog by using `tools/build_catalog/build_catalog.py` script.
40+
41+
Attributes:
42+
name: The name of the catalog.
43+
catalog_path: The path to the catalog JSON file.
44+
examples_path: The path to the examples directory.
45+
"""
3346

3447
name: str
3548
catalog_path: str
3649
examples_path: Optional[str] = None
3750

51+
@classmethod
52+
def for_basic_catalog(cls, examples_path: Optional[str] = None) -> "CatalogConfig":
53+
"""Returns a CatalogConfig for the basic catalog."""
54+
return cls(
55+
name=BASIC_CATALOG_NAME,
56+
catalog_path="", # basic catalog doesn't need a path from the user
57+
examples_path=examples_path,
58+
)
59+
3860

3961
@dataclass(frozen=True)
4062
class A2uiCatalog:
@@ -165,113 +187,6 @@ def load_examples(self, path: Optional[str], validate: bool = False) -> str:
165187
return ""
166188
return "\n\n".join(merged_examples)
167189

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-
275190
def _validate_example(self, full_path: str, basename: str, content: str) -> bool:
276191
try:
277192
json_data = json.loads(content)

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

Lines changed: 51 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
BASIC_CATALOG_NAME,
3636
find_repo_root,
3737
)
38-
from .catalog import CustomCatalogConfig, A2uiCatalog
38+
from .catalog import CatalogConfig, A2uiCatalog
3939
from ...extension.a2ui_extension import INLINE_CATALOGS_KEY, SUPPORTED_CATALOG_IDS_KEY, get_a2ui_agent_extension
4040
from a2a.types import AgentExtension
4141

@@ -118,30 +118,26 @@ class A2uiSchemaManager(InferenceStrategy):
118118
def __init__(
119119
self,
120120
version: str,
121-
basic_examples_path: Optional[str] = None,
122-
custom_catalogs: Optional[List[CustomCatalogConfig]] = None,
123-
exclude_basic_catalog: bool = False,
121+
catalogs: List[CatalogConfig] = [],
124122
accepts_inline_catalogs: bool = False,
125-
schema_modifiers: List[Callable[[Dict[str, Any]], Dict[str, Any]]] = None,
123+
schema_modifiers: List[Callable[[Dict[str, Any]], Dict[str, Any]]] = [],
126124
):
127125
self._version = version
128-
self._exclude_basic_catalog = exclude_basic_catalog
129126
self._accepts_inline_catalogs = accepts_inline_catalogs
130127

131128
self._server_to_client_schema = None
132129
self._common_types_schema = None
133-
self._supported_catalogs: Dict[str, A2uiCatalog] = {}
130+
self._supported_catalogs: List[A2uiCatalog] = []
134131
self._catalog_example_paths: Dict[str, str] = {}
135-
self._basic_catalog = None
136132
self._schema_modifiers = schema_modifiers
137-
self._load_schemas(version, custom_catalogs, basic_examples_path)
133+
self._load_schemas(version, catalogs)
138134

139135
@property
140136
def accepts_inline_catalogs(self) -> bool:
141137
return self._accepts_inline_catalogs
142138

143139
@property
144-
def supported_catalogs(self) -> Dict[str, A2uiCatalog]:
140+
def supported_catalogs(self) -> List[A2uiCatalog]:
145141
return self._supported_catalogs
146142

147143
def _apply_modifiers(self, schema: Dict[str, Any]) -> Dict[str, Any]:
@@ -153,8 +149,7 @@ def _apply_modifiers(self, schema: Dict[str, Any]) -> Dict[str, Any]:
153149
def _load_schemas(
154150
self,
155151
version: str,
156-
custom_catalogs: Optional[List[CustomCatalogConfig]] = None,
157-
basic_examples_path: Optional[str] = None,
152+
catalogs: List[CatalogConfig] = [],
158153
):
159154
"""Loads separate schema components and processes catalogs."""
160155
if version not in SPEC_VERSION_MAP:
@@ -172,11 +167,7 @@ def _load_schemas(
172167
)
173168

174169
# Process basic catalog
175-
basic_catalog_schema = self._apply_modifiers(
176-
_load_basic_component(version, CATALOG_SCHEMA_KEY)
177-
)
178-
if not basic_catalog_schema:
179-
basic_catalog_schema = {}
170+
basic_catalog_schema = _load_basic_component(version, CATALOG_SCHEMA_KEY)
180171

181172
# Ensure catalog id and schema url are set in the basic catalog schema
182173
if CATALOG_ID_KEY not in basic_catalog_schema:
@@ -190,44 +181,36 @@ def _load_schemas(
190181
if "$schema" not in basic_catalog_schema:
191182
basic_catalog_schema["$schema"] = "https://json-schema.org/draft/2020-12/schema"
192183

193-
self._basic_catalog = A2uiCatalog(
194-
version=version,
195-
name=BASIC_CATALOG_NAME,
196-
catalog_schema=basic_catalog_schema,
197-
s2c_schema=self._server_to_client_schema,
198-
common_types_schema=self._common_types_schema,
199-
)
200-
if not self._exclude_basic_catalog:
201-
self._supported_catalogs[self._basic_catalog.catalog_id] = self._basic_catalog
202-
self._catalog_example_paths[self._basic_catalog.catalog_id] = basic_examples_path
203-
204-
# Process custom catalogs
205-
if custom_catalogs:
206-
for config in custom_catalogs:
207-
custom_catalog_schema = self._apply_modifiers(
208-
_load_from_path(config.catalog_path)
209-
)
210-
resolved_catalog_schema = A2uiCatalog.resolve_schema(
211-
basic_catalog_schema, custom_catalog_schema
212-
)
213-
catalog = A2uiCatalog(
214-
version=version,
215-
name=config.name,
216-
catalog_schema=self._apply_modifiers(resolved_catalog_schema),
217-
s2c_schema=self._server_to_client_schema,
218-
common_types_schema=self._common_types_schema,
219-
)
220-
self._supported_catalogs[catalog.catalog_id] = catalog
221-
self._catalog_example_paths[catalog.catalog_id] = config.examples_path
184+
# Process catalogs
185+
if not catalogs:
186+
# If no catalogs are provided, use the basic catalog
187+
catalogs = [CatalogConfig.for_basic_catalog()]
188+
189+
for config in catalogs:
190+
catalog_schema = (
191+
basic_catalog_schema
192+
if config.name == BASIC_CATALOG_NAME
193+
else _load_from_path(config.catalog_path)
194+
)
195+
catalog_schema = self._apply_modifiers(catalog_schema)
196+
catalog = A2uiCatalog(
197+
version=version,
198+
name=config.name,
199+
catalog_schema=catalog_schema,
200+
s2c_schema=self._server_to_client_schema,
201+
common_types_schema=self._common_types_schema,
202+
)
203+
self._supported_catalogs.append(catalog)
204+
self._catalog_example_paths[catalog.catalog_id] = config.examples_path
222205

223206
def _determine_catalog(
224207
self, client_ui_capabilities: Optional[dict[str, Any]] = None
225208
) -> A2uiCatalog:
226209
"""Determines the catalog to use based on supported catalog IDs.
227210
228-
If neither inline catalogs nor supported catalog IDs are provided, the basic catalog is used.
211+
If neither inline catalogs nor client-supported catalog IDs are provided, the first agent-supported catalog is used.
229212
If inline catalogs are provided, the first inline catalog is used.
230-
If supported catalog IDs are provided, the first supported catalog that is recognized is used.
213+
If client-supported catalog IDs are provided, the first one that is supported by the agent is used.
231214
232215
Args:
233216
client_ui_capabilities: A dictionary of client UI capabilities.
@@ -236,16 +219,19 @@ def _determine_catalog(
236219
The A2uiCatalog to use to generate the schema string in the prompt.
237220
238221
Raises:
239-
ValueError: If both inline catalogs and supported catalog IDs are provided,
222+
ValueError: If both inline catalogs and client-supported catalog IDs are provided,
240223
or if no supported catalog is recognized.
241224
"""
225+
if not self._supported_catalogs:
226+
raise ValueError("No supported catalogs found.") # This should not happen.
227+
242228
if not client_ui_capabilities or not isinstance(client_ui_capabilities, dict):
243-
return self._basic_catalog
229+
return self._supported_catalogs[0]
244230

245231
inline_catalogs: List[dict[str, Any]] = client_ui_capabilities.get(
246232
INLINE_CATALOGS_KEY, []
247233
)
248-
supported_catalog_ids: List[str] = client_ui_capabilities.get(
234+
client_supported_catalog_ids: List[str] = client_ui_capabilities.get(
249235
SUPPORTED_CATALOG_IDS_KEY, []
250236
)
251237

@@ -255,37 +241,35 @@ def _determine_catalog(
255241
" capabilities. However, the agent does not accept inline catalogs."
256242
)
257243

258-
if inline_catalogs and supported_catalog_ids:
244+
if inline_catalogs and client_supported_catalog_ids:
259245
raise ValueError(
260246
f"Both '{INLINE_CATALOGS_KEY}' and '{SUPPORTED_CATALOG_IDS_KEY}' "
261247
"are provided in client UI capabilities. Only one is allowed."
262248
)
263249

264250
if inline_catalogs:
265-
# Load the first custom inline catalog schema.
251+
# Load the first inline catalog schema.
266252
inline_catalog_schema = inline_catalogs[0]
267-
resolved_catalog_schema = A2uiCatalog.resolve_schema(
268-
self._basic_catalog.catalog_schema, inline_catalog_schema
269-
)
253+
inline_catalog_schema = self._apply_modifiers(inline_catalog_schema)
270254
return A2uiCatalog(
271255
version=self._version,
272256
name=INLINE_CATALOG_NAME,
273-
catalog_schema=resolved_catalog_schema,
257+
catalog_schema=inline_catalog_schema,
274258
s2c_schema=self._server_to_client_schema,
275259
common_types_schema=self._common_types_schema,
276260
)
277261

278-
if not supported_catalog_ids:
279-
return self._basic_catalog
262+
if not client_supported_catalog_ids:
263+
return self._supported_catalogs[0]
280264

281-
for scid in supported_catalog_ids:
282-
if scid in self._supported_catalogs:
283-
# Return the first supported catalog.
284-
return self._supported_catalogs[scid]
265+
agent_supported_catalogs = {c.catalog_id: c for c in self._supported_catalogs}
266+
for cscid in client_supported_catalog_ids:
267+
if cscid in agent_supported_catalogs:
268+
return agent_supported_catalogs[cscid]
285269

286270
raise ValueError(
287-
"No supported catalog found on the agent side. Agent supported catalogs are:"
288-
f" {list(self._supported_catalogs.keys())}"
271+
"No client-supported catalog found on the agent side. Agent-supported catalogs"
272+
f" are: {[c.catalog_id for c in self._supported_catalogs]}"
289273
)
290274

291275
def get_effective_catalog(
@@ -339,5 +323,5 @@ def generate_system_prompt(
339323
return "\n\n".join(parts)
340324

341325
def get_agent_extension(self) -> AgentExtension:
342-
catalog_ids = self._supported_catalogs.keys()
343-
return get_a2ui_agent_extension(supported_catalog_ids=list(catalog_ids))
326+
catalog_ids = [c.catalog_id for c in self._supported_catalogs]
327+
return get_a2ui_agent_extension(supported_catalog_ids=catalog_ids)

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ def test_manager_with_modifiers():
5858
assert "additionalProperties" not in manager._common_types_schema
5959

6060
# basic catalog should also be modified
61-
for catalog in manager._supported_catalogs.values():
61+
for catalog in manager._supported_catalogs:
6262
assert "additionalProperties" not in catalog.catalog_schema
6363

6464

0 commit comments

Comments
 (0)