1717import logging
1818import os
1919import 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
2221from ..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
3031COMMON_TYPES_SCHEMA_KEY = "common_types"
3132CATALOG_SCHEMA_KEY = "catalog"
3233CATALOG_COMPONENTS_KEY = "components"
34+ CATALOG_STYLES_KEY = "styles"
3335
3436SPEC_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
224403def find_repo_root (start_path : str ) -> str | None :
225404 """Finds the repository root by looking for the 'specification' directory."""
0 commit comments