From 957e27da9a738a7622ee0f9c5022ec969e375ff0 Mon Sep 17 00:00:00 2001 From: Nan Yu Date: Tue, 27 Jan 2026 21:14:24 +0000 Subject: [PATCH 1/6] feat: implement schema packaging and runtime loading - Add a base InferenceStrategy class - Add PackSpecsBuildHook to copy JSON schemas into the package during build time. - Update pyproject.toml to include assets and configure the build hook. - Implement A2uiSchemaManager for robust schema loading, pruning, and prompt generation. --- .../python_a2ui_agent_build_and_test.yml | 8 +- a2a_agents/python/a2ui_agent/.gitignore | 1 + .../python/a2ui_agent/pack_specs_hook.py | 76 +++ a2a_agents/python/a2ui_agent/pyproject.toml | 7 + .../a2ui_agent/src/a2ui/inference/__init__.py | 13 + .../src/a2ui/inference/inference_strategy.py | 47 ++ .../src/a2ui/inference/schema/__init__.py | 13 + .../src/a2ui/inference/schema/catalog.py | 255 ++++++++++ .../src/a2ui/inference/schema/constants.py | 52 ++ .../src/a2ui/inference/schema/loader.py | 69 +++ .../src/a2ui/inference/schema/manager.py | 321 +++++++++++++ .../src/a2ui/inference/template/__init__.py | 13 + .../src/a2ui/inference/template/manager.py | 32 ++ .../tests/inference/test_catalog.py | 160 +++++++ .../inference/test_resolve_catalog_schema.py | 201 ++++++++ .../tests/inference/test_schema_manager.py | 448 ++++++++++++++++++ .../tests/integration/verify_load_real.py | 45 ++ a2a_agents/python/a2ui_agent/uv.lock | 119 ++--- 18 files changed, 1822 insertions(+), 58 deletions(-) create mode 100644 a2a_agents/python/a2ui_agent/.gitignore create mode 100644 a2a_agents/python/a2ui_agent/pack_specs_hook.py create mode 100644 a2a_agents/python/a2ui_agent/src/a2ui/inference/__init__.py create mode 100644 a2a_agents/python/a2ui_agent/src/a2ui/inference/inference_strategy.py create mode 100644 a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/__init__.py create mode 100644 a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/catalog.py create mode 100644 a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/constants.py create mode 100644 a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/loader.py create mode 100644 a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/manager.py create mode 100644 a2a_agents/python/a2ui_agent/src/a2ui/inference/template/__init__.py create mode 100644 a2a_agents/python/a2ui_agent/src/a2ui/inference/template/manager.py create mode 100644 a2a_agents/python/a2ui_agent/tests/inference/test_catalog.py create mode 100644 a2a_agents/python/a2ui_agent/tests/inference/test_resolve_catalog_schema.py create mode 100644 a2a_agents/python/a2ui_agent/tests/inference/test_schema_manager.py create mode 100644 a2a_agents/python/a2ui_agent/tests/integration/verify_load_real.py diff --git a/.github/workflows/python_a2ui_agent_build_and_test.yml b/.github/workflows/python_a2ui_agent_build_and_test.yml index 4d3f44e8e..4ecd60d27 100644 --- a/.github/workflows/python_a2ui_agent_build_and_test.yml +++ b/.github/workflows/python_a2ui_agent_build_and_test.yml @@ -46,10 +46,14 @@ jobs: working-directory: a2a_agents/python/a2ui_agent run: uv run pyink --check . + - name: Run unit tests + working-directory: a2a_agents/python/a2ui_agent + run: uv run --with pytest pytest tests/ + - name: Build the python SDK working-directory: a2a_agents/python/a2ui_agent run: uv build . - - name: Run unit tests + - name: Run validation scripts on assets packing working-directory: a2a_agents/python/a2ui_agent - run: uv run --with pytest pytest tests/ + run: uv run python tests/integration/verify_load_real.py diff --git a/a2a_agents/python/a2ui_agent/.gitignore b/a2a_agents/python/a2ui_agent/.gitignore new file mode 100644 index 000000000..22b91a08b --- /dev/null +++ b/a2a_agents/python/a2ui_agent/.gitignore @@ -0,0 +1 @@ +src/a2ui/assets/**/*.json diff --git a/a2a_agents/python/a2ui_agent/pack_specs_hook.py b/a2a_agents/python/a2ui_agent/pack_specs_hook.py new file mode 100644 index 000000000..ae2dcf693 --- /dev/null +++ b/a2a_agents/python/a2ui_agent/pack_specs_hook.py @@ -0,0 +1,76 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import shutil +import sys +from hatchling.builders.hooks.plugin.interface import BuildHookInterface + + +class PackSpecsBuildHook(BuildHookInterface): + + def initialize(self, version, build_data): + project_root = self.root + + # Add src to sys.path to import the constant + src_path = os.path.join(project_root, "src") + if src_path not in sys.path: + sys.path.insert(0, src_path) + + from a2ui.inference.schema.constants import ( + SPEC_VERSION_MAP, + A2UI_ASSET_PACKAGE, + SPECIFICATION_DIR, + find_repo_root, + ) + + # project root is in a2a_agents/python/a2ui_agent + # Dynamically find repo root by looking for SPECIFICATION_DIR + repo_root = find_repo_root(project_root) + if not repo_root: + # Check for PKG-INFO which implies a packaged state (sdist). + # If PKG-INFO is present, trust the bundled assets. + if os.path.exists(os.path.join(project_root, "PKG-INFO")): + print("Repository root not found, but PKG-INFO present (sdist). Skipping copy.") + return + + raise RuntimeError( + f"Could not find repository root (looked for '{SPECIFICATION_DIR}'" + " directory)." + ) + + # Target directory: src/a2ui/assets + target_base = os.path.join( + project_root, "src", A2UI_ASSET_PACKAGE.replace(".", os.sep) + ) + + for ver, schema_map in SPEC_VERSION_MAP.items(): + target_dir = os.path.join(target_base, ver) + os.makedirs(target_dir, exist_ok=True) + + for _schema_key, source_rel_path in schema_map.items(): + source_path = os.path.join(repo_root, source_rel_path) + + if not os.path.exists(source_path): + print( + f"WARNING: Source schema file not found at {source_path}. Build might" + " produce incomplete wheel if not running from monorepo root." + ) + continue + + filename = os.path.basename(source_path) + dst_file = os.path.join(target_dir, filename) + + print(f"Copying {source_path} -> {dst_file}") + shutil.copy2(source_path, dst_file) diff --git a/a2a_agents/python/a2ui_agent/pyproject.toml b/a2a_agents/python/a2ui_agent/pyproject.toml index 513db8577..f8e3c9fd5 100644 --- a/a2a_agents/python/a2ui_agent/pyproject.toml +++ b/a2a_agents/python/a2ui_agent/pyproject.toml @@ -17,6 +17,13 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/a2ui"] +artifacts = ["src/a2ui/assets/**"] + +[tool.hatch.build.targets.sdist] +artifacts = ["src/a2ui/assets/**"] + +[tool.hatch.build.hooks.custom] +path = "pack_specs_hook.py" [[tool.uv.index]] url = "https://pypi.org/simple" diff --git a/a2a_agents/python/a2ui_agent/src/a2ui/inference/__init__.py b/a2a_agents/python/a2ui_agent/src/a2ui/inference/__init__.py new file mode 100644 index 000000000..4df27a209 --- /dev/null +++ b/a2a_agents/python/a2ui_agent/src/a2ui/inference/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/a2a_agents/python/a2ui_agent/src/a2ui/inference/inference_strategy.py b/a2a_agents/python/a2ui_agent/src/a2ui/inference/inference_strategy.py new file mode 100644 index 000000000..ed1b0789f --- /dev/null +++ b/a2a_agents/python/a2ui_agent/src/a2ui/inference/inference_strategy.py @@ -0,0 +1,47 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from abc import ABC, abstractmethod +from typing import List, Optional, Any + + +class InferenceStrategy(ABC): + + @abstractmethod + def generate_system_prompt( + self, + role_description: str, + workflow_description: str = "", + ui_description: str = "", + supported_catalog_ids: List[str] = [], + allowed_components: List[str] = [], + include_schema: bool = False, + include_examples: bool = False, + ) -> str: + """ + Generates a system prompt for all LLM requests. + + Args: + role_description: Description of the agent's role. + workflow_description: Description of the workflow. + ui_description: Description of the UI. + supported_catalog_ids: List of supported catalog IDs. + allowed_components: List of allowed components. + include_schema: Whether to include the schema. + include_examples: Whether to include examples. + + Returns: + The system prompt. + """ + pass diff --git a/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/__init__.py b/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/__init__.py new file mode 100644 index 000000000..4df27a209 --- /dev/null +++ b/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/catalog.py b/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/catalog.py new file mode 100644 index 000000000..0a53f1a1a --- /dev/null +++ b/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/catalog.py @@ -0,0 +1,255 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import copy +import json +import logging +import os +from dataclasses import dataclass, replace +from typing import Any, Dict, List, Optional + +from .constants import CATALOG_COMPONENTS_KEY, CATALOG_ID_KEY +from referencing import Registry, Resource + + +@dataclass +class CustomCatalogConfig: + """Configuration for a custom component catalog.""" + + name: str + catalog_path: str + examples_path: Optional[str] = None + + +@dataclass(frozen=True) +class A2uiCatalog: + """Represents a processed component catalog with its schema. + + Attributes: + version: The version of the catalog. + name: The name of the catalog. + s2c_schema: The server-to-client schema. + common_types_schema: The common types schema. + catalog_schema: The catalog schema. + """ + + version: str + name: str + s2c_schema: Dict[str, Any] + common_types_schema: Dict[str, Any] + catalog_schema: Dict[str, Any] + + @property + def catalog_id(self) -> str: + if CATALOG_ID_KEY not in self.catalog_schema: + raise ValueError(f"Catalog '{self.name}' missing catalogId") + return self.catalog_schema[CATALOG_ID_KEY] + + def with_pruned_components(self, allowed_components: List[str]) -> "A2uiCatalog": + """Returns a new catalog with only allowed components. + + Args: + allowed_components: List of component names to include. + + Returns: + A copy of the catalog with only allowed components. + """ + + schema_copy = copy.deepcopy(self.catalog_schema) + + # Allow all components if no allowed components are specified + if not allowed_components: + return self + + if CATALOG_COMPONENTS_KEY in schema_copy and isinstance( + schema_copy[CATALOG_COMPONENTS_KEY], dict + ): + all_comps = schema_copy[CATALOG_COMPONENTS_KEY] + schema_copy[CATALOG_COMPONENTS_KEY] = { + k: v for k, v in all_comps.items() if k in allowed_components + } + + # Filter anyComponent oneOf if it exists + # Path: $defs -> anyComponent -> oneOf + if "$defs" in schema_copy and "anyComponent" in schema_copy["$defs"]: + any_comp = schema_copy["$defs"]["anyComponent"] + if "oneOf" in any_comp and isinstance(any_comp["oneOf"], list): + filtered_one_of = [] + for item in any_comp["oneOf"]: + if "$ref" in item: + ref = item["$ref"] + if ref.startswith(f"#/{CATALOG_COMPONENTS_KEY}/"): + comp_name = ref.split("/")[-1] + if comp_name in allowed_components: + filtered_one_of.append(item) + else: + logging.warning(f"Skipping unknown ref format: {ref}") + else: + logging.warning(f"Skipping non-ref item in anyComponent oneOf: {item}") + + any_comp["oneOf"] = filtered_one_of + + return replace(self, catalog_schema=schema_copy) + + def render_as_llm_instructions(self) -> str: + """Renders the catalog and schema as LLM instructions.""" + all_schemas = [] + all_schemas.append("---BEGIN A2UI JSON SCHEMA---") + + server_client_str = ( + json.dumps(self.s2c_schema, indent=2) if self.s2c_schema else "{}" + ) + all_schemas.append(f"### Server To Client Schema:\n{server_client_str}") + + if self.common_types_schema: + common_str = json.dumps(self.common_types_schema, indent=2) + all_schemas.append(f"### Common Types Schema:\n{common_str}") + + catalog_str = json.dumps(self.catalog_schema, indent=2) + all_schemas.append(f"### Catalog Schema:\n{catalog_str}") + + all_schemas.append("---END A2UI JSON SCHEMA---") + + return "\n\n".join(all_schemas) + + def load_examples(self, path: Optional[str]) -> str: + """Loads and validates examples from a directory.""" + if not path or not os.path.isdir(path): + if path: + logging.warning(f"Example path {path} is not a directory") + return "" + + merged_examples = [] + for filename in os.listdir(path): + if filename.endswith(".json"): + full_path = os.path.join(path, filename) + basename = os.path.splitext(filename)[0] + try: + with open(full_path, "r", encoding="utf-8") as f: + content = f.read() + merged_examples.append( + f"---BEGIN {basename}---\n{content}\n---END {basename}---" + ) + except Exception as e: + logging.warning(f"Failed to load example {full_path}: {e}") + if not merged_examples: + return "" + return "\n\n".join(merged_examples) + + @staticmethod + def resolve_schema(basic: Dict[str, Any], custom: Dict[str, Any]) -> Dict[str, Any]: + """Resolves references in custom catalog schema against the basic catalog. + + Args: + basic: The basic catalog schema. + custom: The custom catalog schema. + + Returns: + A new dictionary with references resolved. + """ + result = copy.deepcopy(custom) + + # Initialize registry with basic catalog and maybe others from basic's $id + registry = Registry() + if CATALOG_ID_KEY in basic: + basic_resource = Resource.from_contents(basic) + registry = registry.with_resource(basic[CATALOG_ID_KEY], basic_resource) + + def resolve_ref(ref_uri: str) -> Any: + try: + resolver = registry.resolver() + resolved = resolver.lookup(ref_uri) + return resolved.contents + except Exception as e: + logging.warning("Could not resolve reference %s: %s", ref_uri, e) + return None + + def merge_into(target: Dict[str, Any], source: Dict[str, Any]): + for key, value in source.items(): + if key not in target: + target[key] = copy.deepcopy(value) + + # Process components + if CATALOG_COMPONENTS_KEY in result: + comp_dict = result[CATALOG_COMPONENTS_KEY] + if "$ref" in comp_dict: + resolved = resolve_ref(comp_dict["$ref"]) + if isinstance(resolved, dict): + merge_into(comp_dict, resolved) + del comp_dict["$ref"] + + # Process functions + if "functions" in result: + func_dict = result["functions"] + if "$ref" in func_dict: + resolved = resolve_ref(func_dict["$ref"]) + if isinstance(resolved, dict): + merge_into(func_dict, resolved) + del func_dict["$ref"] + + # Process $defs + if "$defs" in result: + res_defs = result["$defs"] + if "$ref" in res_defs: + resolved = resolve_ref(res_defs["$ref"]) + if isinstance(resolved, dict): + merge_into(res_defs, resolved) + del res_defs["$ref"] + + for name in ["anyComponent", "anyFunction"]: + if name in res_defs: + target = res_defs[name] + one_of = target.get("oneOf", []) + new_one_of = [] + for item in one_of: + if isinstance(item, dict) and "$ref" in item: + ref_uri = item["$ref"] + # Check if it points to basic collector + resolved = resolve_ref(ref_uri) + if isinstance(resolved, dict) and "oneOf" in resolved: + # Merge oneOf items and resolve transitive refs to components/functions + for sub_item in resolved["oneOf"]: + if sub_item not in new_one_of: + new_one_of.append(copy.deepcopy(sub_item)) + # Transitive merge: if sub_item is a ref to a component/function + if isinstance(sub_item, dict) and "$ref" in sub_item: + sub_ref = sub_item["$ref"] + if ( + sub_ref.startswith("#/components/") + and CATALOG_COMPONENTS_KEY in basic + ): + comp_name = sub_ref.split("/")[-1] + if comp_name in basic[CATALOG_COMPONENTS_KEY]: + if CATALOG_COMPONENTS_KEY not in result: + result[CATALOG_COMPONENTS_KEY] = {} + if comp_name not in result[CATALOG_COMPONENTS_KEY]: + result[CATALOG_COMPONENTS_KEY][comp_name] = copy.deepcopy( + basic[CATALOG_COMPONENTS_KEY][comp_name] + ) + elif sub_ref.startswith("#/functions/") and "functions" in basic: + func_name = sub_ref.split("/")[-1] + if func_name in basic["functions"]: + if "functions" not in result: + result["functions"] = {} + if func_name not in result["functions"]: + result["functions"][func_name] = copy.deepcopy( + basic["functions"][func_name] + ) + else: + new_one_of.append(item) + else: + new_one_of.append(item) + target["oneOf"] = new_one_of + + return result diff --git a/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/constants.py b/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/constants.py new file mode 100644 index 000000000..8a8682090 --- /dev/null +++ b/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/constants.py @@ -0,0 +1,52 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os + +A2UI_ASSET_PACKAGE = "a2ui.assets" +SERVER_TO_CLIENT_SCHEMA_KEY = "server_to_client" +COMMON_TYPES_SCHEMA_KEY = "common_types" +CATALOG_SCHEMA_KEY = "catalog" +CATALOG_COMPONENTS_KEY = "components" +CATALOG_ID_KEY = "catalogId" + +BASE_SCHEMA_URL = "https://a2ui.org/" +BASIC_CATALOG_NAME = "basic" +INLINE_CATALOG_NAME = "inline" + +SPEC_VERSION_MAP = { + "0.8": { + SERVER_TO_CLIENT_SCHEMA_KEY: "specification/v0_8/json/server_to_client.json", + CATALOG_SCHEMA_KEY: "specification/v0_8/json/standard_catalog_definition.json", + }, + "0.9": { + SERVER_TO_CLIENT_SCHEMA_KEY: "specification/v0_9/json/server_to_client.json", + CATALOG_SCHEMA_KEY: "specification/v0_9/json/standard_catalog.json", + COMMON_TYPES_SCHEMA_KEY: "specification/v0_9/json/common_types.json", + }, +} + +SPECIFICATION_DIR = "specification" + + +def find_repo_root(start_path: str) -> str | None: + """Finds the repository root by looking for the 'specification' directory.""" + current = os.path.abspath(start_path) + while True: + if os.path.isdir(os.path.join(current, SPECIFICATION_DIR)): + return current + parent = os.path.dirname(current) + if parent == current: + return None + current = parent diff --git a/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/loader.py b/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/loader.py new file mode 100644 index 000000000..5ef09c175 --- /dev/null +++ b/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/loader.py @@ -0,0 +1,69 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import os +import importlib.resources +from typing import List, Dict, Any + +from abc import ABC, abstractmethod + +ENCODING = "utf-8" + + +class A2uiSchemaLoader(ABC): + """Abstract base class for loading schema files.""" + + @abstractmethod + def load(self, filename: str) -> Any: + """Loads a JSON file.""" + pass + + +class FileSystemLoader(A2uiSchemaLoader): + """Loads schema files from the local filesystem. + + This loader assumes that all referenced schema files are located in the + same flat directory structure. + """ + + def __init__(self, base_dir: str): + self.base_dir = base_dir + + def load(self, filename: str) -> Any: + path = os.path.join(self.base_dir, filename) + with open(path, "r", encoding=ENCODING) as f: + return json.load(f) + + +class PackageLoader(A2uiSchemaLoader): + """Loads schema files from package resources. + + This loader assumes that all referenced schema files are located in the + same flat package structure. + """ + + def __init__(self, package_path: str): + self.package_path = package_path + + def load(self, filename: str) -> Any: + try: + traversable = importlib.resources.files(self.package_path) + resource_path = traversable.joinpath(filename) + with resource_path.open("r", encoding=ENCODING) as f: + return json.load(f) + except Exception as e: + raise IOError( + f"Could not load package resource {filename} in {self.package_path}: {e}" + ) from e diff --git a/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/manager.py b/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/manager.py new file mode 100644 index 000000000..4548a10f1 --- /dev/null +++ b/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/manager.py @@ -0,0 +1,321 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import copy +import json +import logging +import os +import importlib.resources +from typing import List, Dict, Any, Optional +from dataclasses import dataclass, field +from .loader import A2uiSchemaLoader, PackageLoader, FileSystemLoader +from ..inference_strategy import InferenceStrategy +from .constants import ( + A2UI_ASSET_PACKAGE, + SERVER_TO_CLIENT_SCHEMA_KEY, + COMMON_TYPES_SCHEMA_KEY, + CATALOG_SCHEMA_KEY, + CATALOG_COMPONENTS_KEY, + CATALOG_ID_KEY, + BASE_SCHEMA_URL, + SPEC_VERSION_MAP, + SPECIFICATION_DIR, + INLINE_CATALOG_NAME, + BASIC_CATALOG_NAME, + find_repo_root, +) +from .catalog import CustomCatalogConfig, A2uiCatalog +from ...extension.a2ui_extension import INLINE_CATALOGS_KEY, SUPPORTED_CATALOG_IDS_KEY + + +def _load_basic_component(version: str, spec_name: str) -> Dict: + """Loads a basic schema component using fallback logic. + + Args: + version: The version of the schema to load. + spec_name: The name of the schema component (e.g. 'server_to_client', 'standard_catalog', 'common_types') to load. + + Returns: + The loaded schema component. + + Raises: + IOError: If the schema file cannot be loaded from any of the fallback locations. + """ + + spec_map = SPEC_VERSION_MAP[version] + if spec_name not in spec_map: + return None + path = spec_map.get(spec_name) + filename = os.path.basename(path) + + # 1. Try to load from package resources + try: + loader = PackageLoader(f"{A2UI_ASSET_PACKAGE}.{version}") + return loader.load(filename) + except Exception as e: + logging.debug("Could not load schema '%s' from package: %s", filename, e) + + # 2. Fallback: Local Assets + # This handles cases where assets might be present in src but not installed + try: + potential_path = os.path.abspath( + os.path.join( + os.path.dirname(__file__), + "..", + "assets", + version, + filename, + ) + ) + loader = FileSystemLoader(os.path.dirname(potential_path)) + return loader.load(filename) + except Exception as e: + logging.debug("Could not load schema '%s' from local assets: %s", filename, e) + + # 3. Fallback: Source Repository (specification/...) + # This handles cases where we are running directly from source tree + # And assets are not yet copied to src/a2ui/assets + # schema_manager.py is at a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/manager.py + # Dynamically find repo root by looking for "specification" directory + try: + repo_root = find_repo_root(os.path.dirname(__file__)) + except Exception as e: + logging.debug("Could not find repo root: %s", e) + + if repo_root: + source_path = os.path.join(repo_root, path) + if os.path.exists(source_path): + loader = FileSystemLoader(os.path.dirname(source_path)) + return loader.load(filename) + + raise IOError(f"Could not load schema {filename} for version {version}") + + +def _load_from_path(path: str) -> Dict: + """Loads a schema from a direct file path.""" + try: + loader = FileSystemLoader(os.path.dirname(path)) + return loader.load(os.path.basename(path)) + except Exception as e: + raise ValueError(f"Failed to load schema at {path}: {e}") + + +class A2uiSchemaManager(InferenceStrategy): + """Manages A2UI schema levels and prompt injection.""" + + def __init__( + self, + version: str, + basic_examples_path: Optional[str] = None, + custom_catalogs: Optional[List[CustomCatalogConfig]] = None, + exclude_basic_catalog: bool = False, + accepts_inline_catalogs: bool = False, + ): + self._version = version + self._exclude_basic_catalog = exclude_basic_catalog + self._accepts_inline_catalogs = accepts_inline_catalogs + + self._server_to_client_schema = None + self._common_types_schema = None + self._supported_catalogs: Dict[str, A2uiCatalog] = {} + self._catalog_example_paths: Dict[str, str] = {} + self._basic_catalog = None + self._load_schemas(version, custom_catalogs, basic_examples_path) + + @property + def accepts_inline_catalogs(self) -> bool: + return self._accepts_inline_catalogs + + @property + def supported_catalogs(self) -> Dict[str, A2uiCatalog]: + return self._supported_catalogs + + def _load_schemas( + self, + version: str, + custom_catalogs: Optional[List[CustomCatalogConfig]] = None, + basic_examples_path: Optional[str] = None, + ): + """Loads separate schema components and processes catalogs.""" + if version not in SPEC_VERSION_MAP: + raise ValueError( + f"Unknown A2UI specification version: {version}. Supported:" + f" {list(SPEC_VERSION_MAP.keys())}" + ) + + # Load server-to-client and common types schemas + self._server_to_client_schema = _load_basic_component( + version, SERVER_TO_CLIENT_SCHEMA_KEY + ) + self._common_types_schema = _load_basic_component(version, COMMON_TYPES_SCHEMA_KEY) + + # Process basic catalog + basic_catalog_schema = _load_basic_component(version, CATALOG_SCHEMA_KEY) + if not basic_catalog_schema: + basic_catalog_schema = {} + + # Ensure catalog id and schema url are set in the basic catalog schema + if CATALOG_ID_KEY not in basic_catalog_schema: + catalog_file = ( + # Strip the `json/` part from the catalog file path. + SPEC_VERSION_MAP[version][CATALOG_SCHEMA_KEY].replace("/json/", "/") + if CATALOG_SCHEMA_KEY in SPEC_VERSION_MAP[version] + else f"specification/{version}/standard_catalog.json" + ) + basic_catalog_schema[CATALOG_ID_KEY] = BASE_SCHEMA_URL + catalog_file + if "$schema" not in basic_catalog_schema: + basic_catalog_schema["$schema"] = "https://json-schema.org/draft/2020-12/schema" + + self._basic_catalog = A2uiCatalog( + version=version, + name=BASIC_CATALOG_NAME, + catalog_schema=basic_catalog_schema, + s2c_schema=self._server_to_client_schema, + common_types_schema=self._common_types_schema, + ) + if not self._exclude_basic_catalog: + self._supported_catalogs[self._basic_catalog.catalog_id] = self._basic_catalog + self._catalog_example_paths[self._basic_catalog.catalog_id] = basic_examples_path + + # Process custom catalogs + if custom_catalogs: + for config in custom_catalogs: + custom_catalog_schema = _load_from_path(config.catalog_path) + resolved_catalog_schema = A2uiCatalog.resolve_schema( + basic_catalog_schema, custom_catalog_schema + ) + catalog = A2uiCatalog( + version=version, + name=config.name, + catalog_schema=resolved_catalog_schema, + s2c_schema=self._server_to_client_schema, + common_types_schema=self._common_types_schema, + ) + self._supported_catalogs[catalog.catalog_id] = catalog + self._catalog_example_paths[catalog.catalog_id] = config.examples_path + + def _determine_catalog( + self, client_ui_capabilities: Optional[dict[str, Any]] = None + ) -> A2uiCatalog: + """Determines the catalog to use based on supported catalog IDs. + + If neither inline catalogs nor supported catalog IDs are provided, the basic catalog is used. + If inline catalogs are provided, the first inline catalog is used. + If supported catalog IDs are provided, the first supported catalog that is recognized is used. + + Args: + client_ui_capabilities: A dictionary of client UI capabilities. + + Returns: + The A2uiCatalog to use to generate the schema string in the prompt. + + Raises: + ValueError: If both inline catalogs and supported catalog IDs are provided, + or if no supported catalog is recognized. + """ + if not client_ui_capabilities or not isinstance(client_ui_capabilities, dict): + return self._basic_catalog + + inline_catalogs: List[dict[str, Any]] = client_ui_capabilities.get( + INLINE_CATALOGS_KEY, [] + ) + supported_catalog_ids: List[str] = client_ui_capabilities.get( + SUPPORTED_CATALOG_IDS_KEY, [] + ) + + if not self._accepts_inline_catalogs and inline_catalogs: + raise ValueError( + f"Inline catalog '{INLINE_CATALOGS_KEY}' is provided in client UI" + " capabilities. However, the agent does not accept inline catalogs." + ) + + if inline_catalogs and supported_catalog_ids: + raise ValueError( + f"Both '{INLINE_CATALOGS_KEY}' and '{SUPPORTED_CATALOG_IDS_KEY}' " + "are provided in client UI capabilities. Only one is allowed." + ) + + if inline_catalogs: + # Load the first custom inline catalog schema. + inline_catalog_schema = inline_catalogs[0] + resolved_catalog_schema = A2uiCatalog.resolve_schema( + self._basic_catalog.catalog_schema, inline_catalog_schema + ) + return A2uiCatalog( + version=self._version, + name=INLINE_CATALOG_NAME, + catalog_schema=resolved_catalog_schema, + s2c_schema=self._server_to_client_schema, + common_types_schema=self._common_types_schema, + ) + + if not supported_catalog_ids: + return self._basic_catalog + + for scid in supported_catalog_ids: + if scid in self._supported_catalogs: + # Return the first supported catalog. + return self._supported_catalogs[scid] + + raise ValueError( + "No supported catalog found on the agent side. Agent supported catalogs are:" + f" {list(self._supported_catalogs.keys())}" + ) + + def get_effective_catalog( + self, + client_ui_capabilities: Optional[dict[str, Any]] = None, + allowed_components: List[str] = [], + ) -> A2uiCatalog: + """Gets the effective catalog after selection and component pruning.""" + catalog = self._determine_catalog(client_ui_capabilities) + pruned_catalog = catalog.with_pruned_components(allowed_components) + return pruned_catalog + + def load_examples(self, catalog: A2uiCatalog) -> str: + """Loads examples for a catalog.""" + if catalog.catalog_id in self._catalog_example_paths: + return catalog.load_examples(self._catalog_example_paths[catalog.catalog_id]) + return "" + + def generate_system_prompt( + self, + role_description: str, + workflow_description: str = "", + ui_description: str = "", + client_ui_capabilities: Optional[dict[str, Any]] = None, + allowed_components: List[str] = [], + include_schema: bool = False, + include_examples: bool = False, + ) -> str: + """Assembles the final system instruction for the LLM.""" + parts = [role_description] + if workflow_description: + parts.append(f"## Workflow Description:\n{workflow_description}") + if ui_description: + parts.append(f"## UI Description:\n{ui_description}") + + final_catalog = self.get_effective_catalog( + client_ui_capabilities, allowed_components + ) + + if include_schema: + parts.append(final_catalog.render_as_llm_instructions()) + + if include_examples: + examples_str = self.load_examples(final_catalog) + if examples_str: + parts.append(f"### Examples:\n{examples_str}") + + return "\n\n".join(parts) diff --git a/a2a_agents/python/a2ui_agent/src/a2ui/inference/template/__init__.py b/a2a_agents/python/a2ui_agent/src/a2ui/inference/template/__init__.py new file mode 100644 index 000000000..4df27a209 --- /dev/null +++ b/a2a_agents/python/a2ui_agent/src/a2ui/inference/template/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/a2a_agents/python/a2ui_agent/src/a2ui/inference/template/manager.py b/a2a_agents/python/a2ui_agent/src/a2ui/inference/template/manager.py new file mode 100644 index 000000000..cab2ee5a6 --- /dev/null +++ b/a2a_agents/python/a2ui_agent/src/a2ui/inference/template/manager.py @@ -0,0 +1,32 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from ..inference_strategy import InferenceStrategy +from typing import List, Optional, Any + + +class A2uiTemplateManager(InferenceStrategy): + + def generate_system_prompt( + self, + role_description: str, + workflow_description: str = "", + ui_description: str = "", + supported_catalog_ids: List[str] = [], + allowed_components: List[str] = [], + include_schema: bool = False, + include_examples: bool = False, + ) -> str: + # TODO: Implementation logic for Template Manager + return "" diff --git a/a2a_agents/python/a2ui_agent/tests/inference/test_catalog.py b/a2a_agents/python/a2ui_agent/tests/inference/test_catalog.py new file mode 100644 index 000000000..89874db82 --- /dev/null +++ b/a2a_agents/python/a2ui_agent/tests/inference/test_catalog.py @@ -0,0 +1,160 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import os +import pytest +from typing import Any, Dict, List +from a2ui.inference.schema.catalog import A2uiCatalog +from a2ui.inference.schema.constants import BASIC_CATALOG_NAME + + +def test_catalog_id_property(): + catalog_id = "https://a2ui.org/basic_catalog.json" + catalog = A2uiCatalog( + version="0.8", + name=BASIC_CATALOG_NAME, + s2c_schema={}, + common_types_schema={}, + catalog_schema={"catalogId": catalog_id}, + ) + assert catalog.catalog_id == catalog_id + + +def test_catalog_id_missing_raises_error(): + catalog = A2uiCatalog( + version="0.8", + name=BASIC_CATALOG_NAME, + s2c_schema={}, + common_types_schema={}, + catalog_schema={}, # No catalogId + ) + with pytest.raises( + ValueError, match=f"Catalog '{BASIC_CATALOG_NAME}' missing catalogId" + ): + _ = catalog.catalog_id + + +def test_load_examples(tmp_path): + example_dir = tmp_path / "examples" + example_dir.mkdir() + (example_dir / "example1.json").write_text('{"foo": "bar"}') + (example_dir / "example2.json").write_text('{"baz": "qux"}') + (example_dir / "ignored.txt").write_text("should not be loaded") + + catalog = A2uiCatalog( + version="0.8", + name=BASIC_CATALOG_NAME, + s2c_schema={}, + common_types_schema={}, + catalog_schema={}, + ) + + examples_str = catalog.load_examples(str(example_dir)) + assert "---BEGIN example1---" in examples_str + assert '{"foo": "bar"}' in examples_str + assert "---BEGIN example2---" in examples_str + assert '{"baz": "qux"}' in examples_str + assert "ignored" not in examples_str + + +def test_load_examples_none_or_invalid_path(): + catalog = A2uiCatalog( + version="0.8", + name=BASIC_CATALOG_NAME, + s2c_schema={}, + common_types_schema={}, + catalog_schema={}, + ) + + assert catalog.load_examples(None) == "" + assert catalog.load_examples("/non/existent/path") == "" + + +def test_with_pruned_components(): + catalog_schema = { + "catalogId": "basic", + "components": { + "Text": {"type": "object"}, + "Button": {"type": "object"}, + "Image": {"type": "object"}, + }, + } + catalog = A2uiCatalog( + version="0.8", + name=BASIC_CATALOG_NAME, + s2c_schema={}, + common_types_schema={}, + catalog_schema=catalog_schema, + ) + + # Test basic pruning + pruned_catalog = catalog.with_pruned_components(["Text", "Button"]) + pruned = pruned_catalog.catalog_schema + assert "Text" in pruned["components"] + assert "Button" in pruned["components"] + assert "Image" not in pruned["components"] + assert pruned_catalog is not catalog # Should be a new instance + + # Test anyComponent oneOf filtering + catalog_schema_with_defs = { + "catalogId": "basic", + "$defs": { + "anyComponent": { + "oneOf": [ + {"$ref": "#/components/Text"}, + {"$ref": "#/components/Button"}, + {"$ref": "#/components/Image"}, + ] + } + }, + "components": {"Text": {}, "Button": {}, "Image": {}}, + } + catalog_with_defs = A2uiCatalog( + version="0.9", + name=BASIC_CATALOG_NAME, + s2c_schema={}, + common_types_schema={}, + catalog_schema=catalog_schema_with_defs, + ) + pruned_catalog_defs = catalog_with_defs.with_pruned_components(["Text"]) + any_comp = pruned_catalog_defs.catalog_schema["$defs"]["anyComponent"] + assert len(any_comp["oneOf"]) == 1 + assert any_comp["oneOf"][0]["$ref"] == "#/components/Text" + + # Test empty allowed components (should return original self) + assert catalog.with_pruned_components([]) is catalog + + +def test_render_as_llm_instructions(): + catalog = A2uiCatalog( + version="0.9", + name=BASIC_CATALOG_NAME, + s2c_schema={"s2c": "schema"}, + common_types_schema={"common": "types"}, + catalog_schema={ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "catalog": "schema", + "catalogId": "id_basic", + }, + ) + + schema_str = catalog.render_as_llm_instructions() + assert "---BEGIN A2UI JSON SCHEMA---" in schema_str + assert '### Server To Client Schema:\n{\n "s2c": "schema"\n}' in schema_str + assert '### Common Types Schema:\n{\n "common": "types"\n}' in schema_str + assert "### Catalog Schema:" in schema_str + assert '"catalog": "schema"' in schema_str + assert '"catalogId": "id_basic"' in schema_str + assert "---END A2UI JSON SCHEMA---" in schema_str diff --git a/a2a_agents/python/a2ui_agent/tests/inference/test_resolve_catalog_schema.py b/a2a_agents/python/a2ui_agent/tests/inference/test_resolve_catalog_schema.py new file mode 100644 index 000000000..20ff4cff0 --- /dev/null +++ b/a2a_agents/python/a2ui_agent/tests/inference/test_resolve_catalog_schema.py @@ -0,0 +1,201 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +from a2ui.inference.schema.catalog import A2uiCatalog +from a2ui.inference.schema.constants import CATALOG_COMPONENTS_KEY, CATALOG_ID_KEY + + +def test_resolve_no_ref(): + custom = { + CATALOG_COMPONENTS_KEY: { + "CustomButton": { + "type": "object", + "properties": {"label": {"type": "string"}}, + } + } + } + basic = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + CATALOG_ID_KEY: "https://a2ui.org/basic_catalog.json", + CATALOG_COMPONENTS_KEY: { + "Text": {"type": "object", "properties": {"text": {"type": "string"}}} + }, + } + resolved = A2uiCatalog.resolve_schema(basic, custom) + assert "CustomButton" in resolved[CATALOG_COMPONENTS_KEY] + assert "Text" not in resolved[CATALOG_COMPONENTS_KEY] + + +def test_resolve_with_ref(): + basic = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + CATALOG_ID_KEY: "https://a2ui.org/basic_catalog.json", + CATALOG_COMPONENTS_KEY: { + "Text": {"type": "object", "properties": {"text": {"type": "string"}}}, + "Image": {"type": "object"}, + }, + } + custom = { + CATALOG_COMPONENTS_KEY: { + "$ref": "https://a2ui.org/basic_catalog.json#/components", + "Canvas": {"type": "object"}, + } + } + resolved = A2uiCatalog.resolve_schema(basic, custom) + assert "Canvas" in resolved[CATALOG_COMPONENTS_KEY] + assert "Text" in resolved[CATALOG_COMPONENTS_KEY] + assert "Image" in resolved[CATALOG_COMPONENTS_KEY] + assert "$ref" not in resolved[CATALOG_COMPONENTS_KEY] + + +def test_resolve_override_without_ref(): + custom = { + CATALOG_COMPONENTS_KEY: { + "Text": {"type": "object", "properties": {"custom": {"type": "string"}}} + } + } + basic = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + CATALOG_ID_KEY: "https://a2ui.org/basic_catalog.json", + CATALOG_COMPONENTS_KEY: { + "Text": {"type": "object", "properties": {"text": {"type": "string"}}} + }, + } + resolved = A2uiCatalog.resolve_schema(basic, custom) + assert "custom" in resolved[CATALOG_COMPONENTS_KEY]["Text"]["properties"] + assert "text" not in resolved[CATALOG_COMPONENTS_KEY]["Text"]["properties"] + + +def test_resolve_any_component_with_ref(): + basic = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + CATALOG_ID_KEY: "https://a2ui.org/basic_catalog.json", + CATALOG_COMPONENTS_KEY: { + "Text": {"type": "object", "properties": {"text": {"type": "string"}}} + }, + "$defs": {"anyComponent": {"oneOf": [{"$ref": "#/components/Text"}]}}, + } + custom = { + CATALOG_COMPONENTS_KEY: { + "CustomComp": {"type": "object", "properties": {"custom": {"type": "string"}}} + }, + "$defs": { + "anyComponent": { + "oneOf": [ + {"$ref": "#/components/CustomComp"}, + {"$ref": "https://a2ui.org/basic_catalog.json#/$defs/anyComponent"}, + ] + } + }, + } + resolved = A2uiCatalog.resolve_schema(basic, custom) + one_of = resolved["$defs"]["anyComponent"]["oneOf"] + assert len(one_of) == 2 + refs = {item["$ref"] for item in one_of} + assert "#/components/CustomComp" in refs + assert "#/components/Text" in refs + assert "CustomComp" in resolved[CATALOG_COMPONENTS_KEY] + assert "Text" in resolved[CATALOG_COMPONENTS_KEY] + + +def test_resolve_any_component_without_ref(): + basic = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + CATALOG_ID_KEY: "https://a2ui.org/basic_catalog.json", + CATALOG_COMPONENTS_KEY: { + "Text": {"type": "object", "properties": {"text": {"type": "string"}}} + }, + "$defs": {"anyComponent": {"oneOf": [{"$ref": "#/components/Text"}]}}, + } + custom = { + CATALOG_COMPONENTS_KEY: { + "CustomComp": {"type": "object", "properties": {"custom": {"type": "string"}}} + }, + "$defs": {"anyComponent": {"oneOf": [{"$ref": "#/components/CustomComp"}]}}, + } + resolved = A2uiCatalog.resolve_schema(basic, custom) + one_of = resolved["$defs"]["anyComponent"]["oneOf"] + assert len(one_of) == 1 + assert one_of[0]["$ref"] == "#/components/CustomComp" + assert "CustomComp" in resolved[CATALOG_COMPONENTS_KEY] + assert "Text" not in resolved[CATALOG_COMPONENTS_KEY] + + +def test_resolve_functions_no_ref(): + custom = {"functions": {"customFunc": {"call": "customFunc"}}} + basic = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + CATALOG_ID_KEY: "https://a2ui.org/basic_catalog.json", + "functions": {"required": {"call": "required"}}, + } + resolved = A2uiCatalog.resolve_schema(basic, custom) + assert "customFunc" in resolved["functions"] + assert "required" not in resolved["functions"] + + +def test_resolve_functions_with_ref(): + basic = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + CATALOG_ID_KEY: "https://a2ui.org/basic_catalog.json", + "functions": {"FuncA": {"type": "object"}, "FuncB": {"type": "object"}}, + } + custom = { + "functions": { + "$ref": "https://a2ui.org/basic_catalog.json#/functions", + "FuncC": {"type": "object"}, + } + } + resolved = A2uiCatalog.resolve_schema(basic, custom) + assert "FuncA" in resolved["functions"] + assert "FuncB" in resolved["functions"] + assert "FuncC" in resolved["functions"] + assert "$ref" not in resolved["functions"] + + +def test_resolve_any_function_with_ref(): + basic = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + CATALOG_ID_KEY: "https://a2ui.org/basic_catalog.json", + "functions": {"FuncA": {"type": "object"}}, + "$defs": {"anyFunction": {"oneOf": [{"$ref": "#/functions/FuncA"}]}}, + } + custom = { + "functions": {"FuncB": {"type": "object"}}, + "$defs": { + "anyFunction": { + "oneOf": [ + {"$ref": "#/functions/FuncB"}, + {"$ref": "https://a2ui.org/basic_catalog.json#/$defs/anyFunction"}, + ] + } + }, + } + resolved = A2uiCatalog.resolve_schema(basic, custom) + one_of = resolved["$defs"]["anyFunction"]["oneOf"] + assert len(one_of) == 2 + refs = {item["$ref"] for item in one_of} + assert "#/functions/FuncA" in refs + assert "#/functions/FuncB" in refs + assert "FuncA" in resolved["functions"] + assert "FuncB" in resolved["functions"] + + +def test_resolve_functions_override(): + basic = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + CATALOG_ID_KEY: "https://a2ui.org/basic_catalog.json", + "functions": {"FuncA": {"properties": {"a": {"type": "string"}}}}, + } + custom = {"functions": {"FuncA": {"properties": {"b": {"type": "string"}}}}} + resolved = A2uiCatalog.resolve_schema(basic, custom) + assert "b" in resolved["functions"]["FuncA"]["properties"] + assert "a" not in resolved["functions"]["FuncA"]["properties"] diff --git a/a2a_agents/python/a2ui_agent/tests/inference/test_schema_manager.py b/a2a_agents/python/a2ui_agent/tests/inference/test_schema_manager.py new file mode 100644 index 000000000..354286290 --- /dev/null +++ b/a2a_agents/python/a2ui_agent/tests/inference/test_schema_manager.py @@ -0,0 +1,448 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +import io +import pytest +import json +import os +from unittest.mock import patch, MagicMock, PropertyMock +from a2ui.inference.schema.manager import A2uiSchemaManager, A2uiCatalog, CustomCatalogConfig +from a2ui.inference.schema.constants import ( + CATALOG_COMPONENTS_KEY, + INLINE_CATALOG_NAME, + BASIC_CATALOG_NAME, +) +from a2ui.extension.a2ui_extension import INLINE_CATALOGS_KEY, SUPPORTED_CATALOG_IDS_KEY + +test_version = "0.8" + + +@pytest.fixture +def mock_importlib_resources(): + with patch("importlib.resources.files") as mock_files: + yield mock_files + + +def test_schema_manager_init_valid_version(mock_importlib_resources): + mock_files = mock_importlib_resources + mock_traversable = MagicMock() + + def files_side_effect(package): + if package == f"a2ui.assets.{test_version}": + return mock_traversable + return MagicMock() + + mock_files.side_effect = files_side_effect + + # Mock file open calls for server_to_client and catalog + def joinpath_side_effect(path): + mock_file = MagicMock() + if path == "server_to_client.json": + content = ( + '{"$schema": "https://json-schema.org/draft/2020-12/schema", "version":' + ' "0.8", "defs": "server_defs"}' + ) + elif path == "standard_catalog_definition.json": + content = ( + '{"$schema": "https://json-schema.org/draft/2020-12/schema", "version":' + ' "0.8", "components": {"Text": {}}}' + ) + else: + content = '{"$schema": "https://json-schema.org/draft/2020-12/schema"}' + + mock_file.open.return_value.__enter__.return_value = io.StringIO(content) + return mock_file + + mock_traversable.joinpath.side_effect = joinpath_side_effect + + manager = A2uiSchemaManager(test_version) + + assert manager._server_to_client_schema["defs"] == "server_defs" + # Basic catalog might have a URI-based ID if not explicitly matched + # So we check if any catalog exists + assert len(manager._supported_catalogs) >= 1 + # The first one should be the basic one + catalog = list(manager._supported_catalogs.values())[0] + assert catalog.catalog_schema["version"] == test_version + assert "Text" in catalog.catalog_schema["components"] + + +def test_schema_manager_fallback_local_assets(mock_importlib_resources): + # Force importlib to fail + mock_importlib_resources.side_effect = FileNotFoundError("Package not found") + + with ( + patch("os.path.exists") as mock_exists, + patch("builtins.open", new_callable=MagicMock) as mock_open, + ): + + def open_side_effect(path, *args, **kwargs): + path_str = str(path) + if "server_to_client" in path_str: + return io.StringIO('{"defs": "local_server"}') + elif "standard_catalog" in path_str or "catalog" in path_str: + return io.StringIO('{"catalogId": "basic", "components": {"LocalText": {}}}') + raise FileNotFoundError(path) + + mock_open.side_effect = open_side_effect + + manager = A2uiSchemaManager(test_version) + + assert manager._server_to_client_schema["defs"] == "local_server" + catalog = list(manager._supported_catalogs.values())[0] + assert "LocalText" in catalog.catalog_schema["components"] + + +def test_schema_manager_init_invalid_version(): + with pytest.raises(ValueError, match="Unknown A2UI specification version"): + A2uiSchemaManager("invalid_version") + + +def test_schema_manager_init_custom_catalog(tmp_path, mock_importlib_resources): + mock_files = mock_importlib_resources + mock_traversable = MagicMock() + mock_files.return_value = mock_traversable + + def joinpath_side_effect(path): + mock_file = MagicMock() + if "server_to_client" in path: + mock_file.open.return_value.__enter__.return_value = io.StringIO( + '{"$schema": "https://json-schema.org/draft/2020-12/schema"}' + ) + elif "catalog" in path: + mock_file.open.return_value.__enter__.return_value = io.StringIO( + '{"$schema": "https://json-schema.org/draft/2020-12/schema", "catalogId":' + ' "basic", "components": {}}' + ) + else: + mock_file.open.return_value.__enter__.return_value = io.StringIO( + '{"$schema": "https://json-schema.org/draft/2020-12/schema"}' + ) + return mock_file + + mock_traversable.joinpath.side_effect = joinpath_side_effect + + d = tmp_path / "custom_catalog.json" + d.write_text( + '{"$schema": "https://json-schema.org/draft/2020-12/schema", "catalogId":' + ' "Custom", "components": {"Custom": {}}}', + encoding="utf-8", + ) + + config = CustomCatalogConfig(name="Custom", catalog_path=str(d)) + manager = A2uiSchemaManager(test_version, custom_catalogs=[config]) + + assert len(manager._supported_catalogs) == 2 + assert "basic" in manager._supported_catalogs + assert "Custom" in manager._supported_catalogs + assert "Custom" in manager._supported_catalogs["Custom"].catalog_schema["components"] + + +def test_generate_system_prompt(mock_importlib_resources): + mock_files = mock_importlib_resources + mock_traversable = MagicMock() + mock_files.return_value = mock_traversable + + def joinpath_side_effect(path): + mock_file = MagicMock() + if "server_to_client" in path: + content = ( + '{"$schema": "https://json-schema.org/draft/2020-12/schema", "type":' + ' "object", "properties": {"server_schema": {"type": "string"}}}' + ) + elif "catalog" in path: + content = ( + '{"$schema": "https://json-schema.org/draft/2020-12/schema", "catalogId":' + ' "basic", "components": {"Text": {}}}' + ) + else: + content = '{"$schema": "https://json-schema.org/draft/2020-12/schema"}' + mock_file.open.return_value.__enter__.return_value = io.StringIO(content) + return mock_file + + mock_traversable.joinpath.side_effect = joinpath_side_effect + + manager = A2uiSchemaManager("0.8") + prompt = manager.generate_system_prompt( + role_description="You are a helpful assistant.", + workflow_description="Manage workflow.", + ui_description="Render UI.", + client_ui_capabilities={SUPPORTED_CATALOG_IDS_KEY: ["basic"]}, + allowed_components=["Text"], + include_schema=True, + ) + + assert "You are a helpful assistant." in prompt + assert "## Workflow Description:" in prompt + assert "Manage workflow." in prompt + assert "## UI Description:" in prompt + assert "RENDERUI." in prompt.replace(" ", "").upper() + assert "---BEGIN A2UI JSON SCHEMA---" in prompt + assert "### Server To Client Schema:" in prompt + assert "### Catalog Schema" in prompt + assert "---END A2UI JSON SCHEMA---" in prompt + assert '"Text":{}' in prompt.replace(" ", "") + + +def test_generate_system_prompt_with_examples(mock_importlib_resources): + mock_files = mock_importlib_resources + mock_traversable = MagicMock() + mock_files.return_value = mock_traversable + + def joinpath_side_effect(path): + mock_file = MagicMock() + if "catalog" in path: + content = '{"catalogId": "basic", "components": {}}' + else: + content = "{}" + mock_file.open.return_value.__enter__.return_value = io.StringIO(content) + return mock_file + + mock_traversable.joinpath.side_effect = joinpath_side_effect + + manager = A2uiSchemaManager("0.8") + + # Test with examples + with patch("os.path.isdir", return_value=True): + with patch.object( + A2uiCatalog, + "load_examples", + return_value="---BEGIN example1---\n{}\n---END example1---", + ): + prompt = manager.generate_system_prompt("Role description", include_examples=True) + assert "### Examples:" in prompt + assert "example1" in prompt + + # Test without examples + prompt_no_examples = manager.generate_system_prompt("Role description") + assert "## Examples:" not in prompt_no_examples + + +def test_generate_system_prompt_v0_9_common_types(mock_importlib_resources): + mock_files = mock_importlib_resources + mock_traversable = MagicMock() + mock_files.return_value = mock_traversable + + def joinpath_side_effect(path): + mock_file = MagicMock() + content = '{"$schema": "https://json-schema.org/draft/2020-12/schema"}' + if path == "common_types.json": + content = ( + '{"$schema": "https://json-schema.org/draft/2020-12/schema", "types":' + ' {"Common": {}}}' + ) + elif "server_to_client" in path: + content = ( + '{"$schema": "https://json-schema.org/draft/2020-12/schema", "type":' + ' "object", "properties": {"server_schema": {"type": "string"}}}' + ) + elif "catalog" in path: + content = ( + '{"$schema": "https://json-schema.org/draft/2020-12/schema", "catalogId":' + ' "basic", "components": {}}' + ) + + mock_file.open.return_value.__enter__.return_value = io.StringIO(content) + return mock_file + + mock_traversable.joinpath.side_effect = joinpath_side_effect + + # Initialize with version 0.9 which expects common types + manager = A2uiSchemaManager("0.9") + + prompt = manager.generate_system_prompt("Role", include_schema=True) + + assert "### Common Types Schema:" in prompt + assert '"types":{"Common":{}}' in prompt.replace(" ", "").replace("\n", "") + + +def test_generate_system_prompt_minimal_args(mock_importlib_resources): + mock_files = mock_importlib_resources + mock_traversable = MagicMock() + mock_files.return_value = mock_traversable + + def joinpath_side_effect(path): + mock_file = MagicMock() + if "catalog" in path: + content = '{"catalogId": "basic", "components": {}}' + else: + content = "{}" + mock_file.open.return_value.__enter__.return_value = io.StringIO(content) + return mock_file + + mock_traversable.joinpath.side_effect = joinpath_side_effect + + manager = A2uiSchemaManager("0.8") + prompt = manager.generate_system_prompt("Just Role") + + # Check that optional sections are missing + assert "## Workflow Description:" not in prompt + assert "## UI Description:" not in prompt + assert "## Examples:" not in prompt + assert "Just Role" in prompt + assert "---BEGIN A2UI JSON SCHEMA---" not in prompt + + +def test_generate_system_prompt_with_inline_catalog(mock_importlib_resources): + mock_files = mock_importlib_resources + mock_traversable = MagicMock() + mock_files.return_value = mock_traversable + + def joinpath_side_effect(path): + mock_file = MagicMock() + content = '{"$schema": "https://json-schema.org/draft/2020-12/schema"}' + if "catalog" in path: + content = ( + '{"$schema": "https://json-schema.org/draft/2020-12/schema", "catalogId":' + ' "basic", "components": {}}' + ) + mock_file.open.return_value.__enter__.return_value = io.StringIO(content) + return mock_file + + mock_traversable.joinpath.side_effect = joinpath_side_effect + manager = A2uiSchemaManager("0.8", accepts_inline_catalogs=True) + inline_schema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "catalogId": "id_inline", + "components": {"Button": {}}, + } + client_caps = {INLINE_CATALOGS_KEY: [inline_schema]} + + prompt = manager.generate_system_prompt( + "Role", client_ui_capabilities=client_caps, include_schema=True + ) + + assert "Role" in prompt + assert "---BEGIN A2UI JSON SCHEMA---" in prompt + assert ( + '### Catalog Schema:\n{\n "$schema":' + ' "https://json-schema.org/draft/2020-12/schema",\n "catalogId": "id_inline"' + in prompt + ) + assert '"Button": {}' in prompt + + +def test_determine_catalog_logic(): + basic = A2uiCatalog( + version="0.9", + name=BASIC_CATALOG_NAME, + s2c_schema={}, + common_types_schema={}, + catalog_schema={ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "catalogId": "id_basic", + }, + ) + custom1 = A2uiCatalog( + version="0.9", + name="custom1", + s2c_schema={}, + common_types_schema={}, + catalog_schema={ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "catalogId": "id_custom1", + }, + ) + custom2 = A2uiCatalog( + version="0.9", + name="custom2", + s2c_schema={}, + common_types_schema={}, + catalog_schema={ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "catalogId": "id_custom2", + }, + ) + + # Create a mock manager with these catalogs + manager = MagicMock(spec=A2uiSchemaManager) + manager._supported_catalogs = { + basic.catalog_id: basic, + custom1.catalog_id: custom1, + custom2.catalog_id: custom2, + } + manager._version = "0.9" + manager._server_to_client_schema = {"s2c": "schema"} + manager._common_types_schema = {"common": "types"} + manager._basic_catalog = basic + manager._accepts_inline_catalogs = True + + # Rule 1: If supported_catalog_ids is not provided, return the basic catalog + assert A2uiSchemaManager._determine_catalog(manager, {}) == basic + assert A2uiSchemaManager._determine_catalog(manager, None) == basic + + # Rule 2: Exception if both inline and supported IDs are provided + with pytest.raises(ValueError, match="Only one is allowed"): + A2uiSchemaManager._determine_catalog( + manager, + { + INLINE_CATALOGS_KEY: [{"inline": "catalog"}], + SUPPORTED_CATALOG_IDS_KEY: ["id_custom1"], + }, + ) + + # Rule 3: Inline catalog loading + inline_schema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "catalogId": "id_inline", + "components": {}, + } + # Mock A2uiCatalog.resolve_schema + with patch.object(A2uiCatalog, "resolve_schema", return_value=inline_schema): + catalog_inline = A2uiSchemaManager._determine_catalog( + manager, {INLINE_CATALOGS_KEY: [inline_schema]} + ) + assert catalog_inline.name == INLINE_CATALOG_NAME + assert catalog_inline.catalog_schema == inline_schema + assert catalog_inline.s2c_schema == manager._server_to_client_schema + assert catalog_inline.common_types_schema == manager._common_types_schema + + # Rule 3b: Inline catalog loading should fail if not accepted. + manager._accepts_inline_catalogs = False + with pytest.raises(ValueError, match="the agent does not accept inline catalogs"): + A2uiSchemaManager._determine_catalog( + manager, {INLINE_CATALOGS_KEY: [inline_schema]} + ) + manager._accepts_inline_catalogs = True + + # Rule 4: Otherwise, find the intersection, return any catalog that matches. + # The priority is determined by the order in supported_catalog_ids. + assert ( + A2uiSchemaManager._determine_catalog( + manager, {SUPPORTED_CATALOG_IDS_KEY: ["id_custom1"]} + ) + == custom1 + ) + assert ( + A2uiSchemaManager._determine_catalog( + manager, {SUPPORTED_CATALOG_IDS_KEY: ["id_custom2", "id_custom1"]} + ) + == custom2 + ) # returns first match in supported list + assert ( + A2uiSchemaManager._determine_catalog( + manager, {SUPPORTED_CATALOG_IDS_KEY: ["id_basic", "id_custom2"]} + ) + == basic + ) # returns first match in supported list (basic is first) + + # Rule 5: Raise ValueError if supported list is non-empty but no match exists + with pytest.raises(ValueError, match="No supported catalog found"): + A2uiSchemaManager._determine_catalog( + manager, {SUPPORTED_CATALOG_IDS_KEY: ["id_not_exists"]} + ) + + assert ( + A2uiSchemaManager._determine_catalog( + manager, {SUPPORTED_CATALOG_IDS_KEY: ["id_basic"]} + ) + == basic + ) diff --git a/a2a_agents/python/a2ui_agent/tests/integration/verify_load_real.py b/a2a_agents/python/a2ui_agent/tests/integration/verify_load_real.py new file mode 100644 index 000000000..9b039bd43 --- /dev/null +++ b/a2a_agents/python/a2ui_agent/tests/integration/verify_load_real.py @@ -0,0 +1,45 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import sys + +from a2ui.inference.schema.manager import A2uiSchemaManager +from a2ui.inference.schema.constants import CATALOG_COMPONENTS_KEY + + +def verify(): + print("Verifying A2uiSchemaManager...") + try: + manager = A2uiSchemaManager("0.8") + catalog = manager.get_effective_catalog() + catalog_components = catalog.catalog_schema[CATALOG_COMPONENTS_KEY] + print(f"Successfully loaded 0.8: {len(catalog_components)} components") + print(f"Components found: {list(catalog_components.keys())[:5]}...") + except Exception as e: + print(f"Failed to load 0.8: {e}") + sys.exit(1) + + try: + manager = A2uiSchemaManager("0.9") + catalog = manager.get_effective_catalog() + catalog_components = catalog.catalog_schema[CATALOG_COMPONENTS_KEY] + print(f"Successfully loaded 0.9: {len(catalog_components)} components") + print(f"Components found: {list(catalog_components.keys())}...") + except Exception as e: + print(f"Failed to load 0.9: {e}") + sys.exit(1) + + +if __name__ == "__main__": + verify() diff --git a/a2a_agents/python/a2ui_agent/uv.lock b/a2a_agents/python/a2ui_agent/uv.lock index 3c413688b..62f5effd3 100644 --- a/a2a_agents/python/a2ui_agent/uv.lock +++ b/a2a_agents/python/a2ui_agent/uv.lock @@ -485,7 +485,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -509,7 +509,7 @@ wheels = [ [[package]] name = "google-adk" -version = "1.22.1" +version = "1.23.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiosqlite" }, @@ -556,9 +556,9 @@ dependencies = [ { name = "watchdog" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a9/7e/1fe2704d8079d93bffb5888aa2bd1081251855e8bf14d97f648abd9fd7fa/google_adk-1.22.1.tar.gz", hash = "sha256:4590df0a9340cf05cf5a9899986dfcc3db1c624c6165d76c04be16de535e6404", size = 2046783, upload-time = "2026-01-12T20:50:08.064Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/25/a8c7058812ae3a6046c1c909da31b4c95a6534f555ec50730fe215b2592c/google_adk-1.23.0.tar.gz", hash = "sha256:07829b3198d412ecddb8b102c6bc9511607a234989b7659be102d806e4c92258", size = 2072294, upload-time = "2026-01-22T23:26:52.352Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/17/ea/5fee720ca26eff38e338ff0dc1eae4abd06fc712b841333689f5caf4c55f/google_adk-1.22.1-py3-none-any.whl", hash = "sha256:65c921a1343220eb7823ec8972479c046e7d9464f17c0829fb5508551678a9ef", size = 2368855, upload-time = "2026-01-12T20:50:06.087Z" }, + { url = "https://files.pythonhosted.org/packages/60/36/2abbcaad2bd4691863ac05189070c1e9f8d12ec16194f41a975c984883af/google_adk-1.23.0-py3-none-any.whl", hash = "sha256:94b77c9afa39042e77a35c2b3dad7e122d940e065cb5a9ba9e7b5de73786f993", size = 2418796, upload-time = "2026-01-22T23:26:50.289Z" }, ] [[package]] @@ -994,7 +994,7 @@ wheels = [ [[package]] name = "google-genai" -version = "1.59.0" +version = "1.60.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1008,9 +1008,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/40/34/c03bcbc759d67ac3d96077838cdc1eac85417de6ea3b65b313fe53043eee/google_genai-1.59.0.tar.gz", hash = "sha256:0b7a2dc24582850ae57294209d8dfc2c4f5fcfde0a3f11d81dc5aca75fb619e2", size = 487374, upload-time = "2026-01-15T20:29:46.619Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/3f/a753be0dcee352b7d63bc6d1ba14a72591d63b6391dac0cdff7ac168c530/google_genai-1.60.0.tar.gz", hash = "sha256:9768061775fddfaecfefb0d6d7a6cabefb3952ebd246cd5f65247151c07d33d1", size = 487721, upload-time = "2026-01-21T22:17:30.398Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/53/6d00692fe50d73409b3406ae90c71bc4499c8ae7fac377ba16e283da917c/google_genai-1.59.0-py3-none-any.whl", hash = "sha256:59fc01a225d074fe9d1e626c3433da292f33249dadce4deb34edea698305a6df", size = 719099, upload-time = "2026-01-15T20:29:44.604Z" }, + { url = "https://files.pythonhosted.org/packages/31/e5/384b1f383917b5f0ae92e28f47bc27b16e3d26cd9bacb25e9f8ecab3c8fe/google_genai-1.60.0-py3-none-any.whl", hash = "sha256:967338378ffecebec19a8ed90cf8797b26818bacbefd7846a9280beb1099f7f3", size = 719431, upload-time = "2026-01-21T22:17:28.086Z" }, ] [[package]] @@ -1703,11 +1703,11 @@ wheels = [ [[package]] name = "packaging" -version = "25.0" +version = "26.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] [[package]] @@ -1844,11 +1844,11 @@ wheels = [ [[package]] name = "pycparser" -version = "2.23" +version = "3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] [[package]] @@ -2043,11 +2043,11 @@ crypto = [ [[package]] name = "pyparsing" -version = "3.3.1" +version = "3.3.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/33/c1/1d9de9aeaa1b89b0186e5fe23294ff6517fce1bc69149185577cd31016b2/pyparsing-3.3.1.tar.gz", hash = "sha256:47fad0f17ac1e2cad3de3b458570fbc9b03560aa029ed5e16ee5554da9a2251c", size = 1550512, upload-time = "2025-12-23T03:14:04.391Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/40/2614036cdd416452f5bf98ec037f38a1afb17f327cb8e6b652d4729e0af8/pyparsing-3.3.1-py3-none-any.whl", hash = "sha256:023b5e7e5520ad96642e2c6db4cb683d3970bd640cdf7115049a6e9c3682df82", size = 121793, upload-time = "2025-12-23T03:14:02.103Z" }, + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, ] [[package]] @@ -2420,51 +2420,58 @@ wheels = [ [[package]] name = "sqlalchemy" -version = "2.0.45" +version = "2.0.46" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/be/f9/5e4491e5ccf42f5d9cfc663741d261b3e6e1683ae7812114e7636409fcc6/sqlalchemy-2.0.45.tar.gz", hash = "sha256:1632a4bda8d2d25703fdad6363058d882541bdaaee0e5e3ddfa0cd3229efce88", size = 9869912, upload-time = "2025-12-09T21:05:16.737Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/70/75b1387d72e2847220441166c5eb4e9846dd753895208c13e6d66523b2d9/sqlalchemy-2.0.45-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c64772786d9eee72d4d3784c28f0a636af5b0a29f3fe26ff11f55efe90c0bd85", size = 2154148, upload-time = "2025-12-10T20:03:21.023Z" }, - { url = "https://files.pythonhosted.org/packages/d8/a4/7805e02323c49cb9d1ae5cd4913b28c97103079765f520043f914fca4cb3/sqlalchemy-2.0.45-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7ae64ebf7657395824a19bca98ab10eb9a3ecb026bf09524014f1bb81cb598d4", size = 3233051, upload-time = "2025-12-09T22:06:04.768Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ec/32ae09139f61bef3de3142e85c47abdee8db9a55af2bb438da54a4549263/sqlalchemy-2.0.45-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f02325709d1b1a1489f23a39b318e175a171497374149eae74d612634b234c0", size = 3232781, upload-time = "2025-12-09T22:09:54.435Z" }, - { url = "https://files.pythonhosted.org/packages/ad/bd/bf7b869b6f5585eac34222e1cf4405f4ba8c3b85dd6b1af5d4ce8bca695f/sqlalchemy-2.0.45-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d2c3684fca8a05f0ac1d9a21c1f4a266983a7ea9180efb80ffeb03861ecd01a0", size = 3182096, upload-time = "2025-12-09T22:06:06.169Z" }, - { url = "https://files.pythonhosted.org/packages/21/6a/c219720a241bb8f35c88815ccc27761f5af7fdef04b987b0e8a2c1a6dcaa/sqlalchemy-2.0.45-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:040f6f0545b3b7da6b9317fc3e922c9a98fc7243b2a1b39f78390fc0942f7826", size = 3205109, upload-time = "2025-12-09T22:09:55.969Z" }, - { url = "https://files.pythonhosted.org/packages/bd/c4/6ccf31b2bc925d5d95fab403ffd50d20d7c82b858cf1a4855664ca054dce/sqlalchemy-2.0.45-cp310-cp310-win32.whl", hash = "sha256:830d434d609fe7bfa47c425c445a8b37929f140a7a44cdaf77f6d34df3a7296a", size = 2114240, upload-time = "2025-12-09T21:29:54.007Z" }, - { url = "https://files.pythonhosted.org/packages/de/29/a27a31fca07316def418db6f7c70ab14010506616a2decef1906050a0587/sqlalchemy-2.0.45-cp310-cp310-win_amd64.whl", hash = "sha256:0209d9753671b0da74da2cfbb9ecf9c02f72a759e4b018b3ab35f244c91842c7", size = 2137615, upload-time = "2025-12-09T21:29:55.85Z" }, - { url = "https://files.pythonhosted.org/packages/a2/1c/769552a9d840065137272ebe86ffbb0bc92b0f1e0a68ee5266a225f8cd7b/sqlalchemy-2.0.45-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2e90a344c644a4fa871eb01809c32096487928bd2038bf10f3e4515cb688cc56", size = 2153860, upload-time = "2025-12-10T20:03:23.843Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f8/9be54ff620e5b796ca7b44670ef58bc678095d51b0e89d6e3102ea468216/sqlalchemy-2.0.45-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8c8b41b97fba5f62349aa285654230296829672fc9939cd7f35aab246d1c08b", size = 3309379, upload-time = "2025-12-09T22:06:07.461Z" }, - { url = "https://files.pythonhosted.org/packages/f6/2b/60ce3ee7a5ae172bfcd419ce23259bb874d2cddd44f67c5df3760a1e22f9/sqlalchemy-2.0.45-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12c694ed6468333a090d2f60950e4250b928f457e4962389553d6ba5fe9951ac", size = 3309948, upload-time = "2025-12-09T22:09:57.643Z" }, - { url = "https://files.pythonhosted.org/packages/a3/42/bac8d393f5db550e4e466d03d16daaafd2bad1f74e48c12673fb499a7fc1/sqlalchemy-2.0.45-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f7d27a1d977a1cfef38a0e2e1ca86f09c4212666ce34e6ae542f3ed0a33bc606", size = 3261239, upload-time = "2025-12-09T22:06:08.879Z" }, - { url = "https://files.pythonhosted.org/packages/6f/12/43dc70a0528c59842b04ea1c1ed176f072a9b383190eb015384dd102fb19/sqlalchemy-2.0.45-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d62e47f5d8a50099b17e2bfc1b0c7d7ecd8ba6b46b1507b58cc4f05eefc3bb1c", size = 3284065, upload-time = "2025-12-09T22:09:59.454Z" }, - { url = "https://files.pythonhosted.org/packages/cf/9c/563049cf761d9a2ec7bc489f7879e9d94e7b590496bea5bbee9ed7b4cc32/sqlalchemy-2.0.45-cp311-cp311-win32.whl", hash = "sha256:3c5f76216e7b85770d5bb5130ddd11ee89f4d52b11783674a662c7dd57018177", size = 2113480, upload-time = "2025-12-09T21:29:57.03Z" }, - { url = "https://files.pythonhosted.org/packages/bc/fa/09d0a11fe9f15c7fa5c7f0dd26be3d235b0c0cbf2f9544f43bc42efc8a24/sqlalchemy-2.0.45-cp311-cp311-win_amd64.whl", hash = "sha256:a15b98adb7f277316f2c276c090259129ee4afca783495e212048daf846654b2", size = 2138407, upload-time = "2025-12-09T21:29:58.556Z" }, - { url = "https://files.pythonhosted.org/packages/2d/c7/1900b56ce19bff1c26f39a4ce427faec7716c81ac792bfac8b6a9f3dca93/sqlalchemy-2.0.45-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3ee2aac15169fb0d45822983631466d60b762085bc4535cd39e66bea362df5f", size = 3333760, upload-time = "2025-12-09T22:11:02.66Z" }, - { url = "https://files.pythonhosted.org/packages/0a/93/3be94d96bb442d0d9a60e55a6bb6e0958dd3457751c6f8502e56ef95fed0/sqlalchemy-2.0.45-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba547ac0b361ab4f1608afbc8432db669bd0819b3e12e29fb5fa9529a8bba81d", size = 3348268, upload-time = "2025-12-09T22:13:49.054Z" }, - { url = "https://files.pythonhosted.org/packages/48/4b/f88ded696e61513595e4a9778f9d3f2bf7332cce4eb0c7cedaabddd6687b/sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215f0528b914e5c75ef2559f69dca86878a3beeb0c1be7279d77f18e8d180ed4", size = 3278144, upload-time = "2025-12-09T22:11:04.14Z" }, - { url = "https://files.pythonhosted.org/packages/ed/6a/310ecb5657221f3e1bd5288ed83aa554923fb5da48d760a9f7622afeb065/sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:107029bf4f43d076d4011f1afb74f7c3e2ea029ec82eb23d8527d5e909e97aa6", size = 3313907, upload-time = "2025-12-09T22:13:50.598Z" }, - { url = "https://files.pythonhosted.org/packages/5c/39/69c0b4051079addd57c84a5bfb34920d87456dd4c90cf7ee0df6efafc8ff/sqlalchemy-2.0.45-cp312-cp312-win32.whl", hash = "sha256:0c9f6ada57b58420a2c0277ff853abe40b9e9449f8d7d231763c6bc30f5c4953", size = 2112182, upload-time = "2025-12-09T21:39:30.824Z" }, - { url = "https://files.pythonhosted.org/packages/f7/4e/510db49dd89fc3a6e994bee51848c94c48c4a00dc905e8d0133c251f41a7/sqlalchemy-2.0.45-cp312-cp312-win_amd64.whl", hash = "sha256:8defe5737c6d2179c7997242d6473587c3beb52e557f5ef0187277009f73e5e1", size = 2139200, upload-time = "2025-12-09T21:39:32.321Z" }, - { url = "https://files.pythonhosted.org/packages/6a/c8/7cc5221b47a54edc72a0140a1efa56e0a2730eefa4058d7ed0b4c4357ff8/sqlalchemy-2.0.45-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe187fc31a54d7fd90352f34e8c008cf3ad5d064d08fedd3de2e8df83eb4a1cf", size = 3277082, upload-time = "2025-12-09T22:11:06.167Z" }, - { url = "https://files.pythonhosted.org/packages/0e/50/80a8d080ac7d3d321e5e5d420c9a522b0aa770ec7013ea91f9a8b7d36e4a/sqlalchemy-2.0.45-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:672c45cae53ba88e0dad74b9027dddd09ef6f441e927786b05bec75d949fbb2e", size = 3293131, upload-time = "2025-12-09T22:13:52.626Z" }, - { url = "https://files.pythonhosted.org/packages/da/4c/13dab31266fc9904f7609a5dc308a2432a066141d65b857760c3bef97e69/sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:470daea2c1ce73910f08caf10575676a37159a6d16c4da33d0033546bddebc9b", size = 3225389, upload-time = "2025-12-09T22:11:08.093Z" }, - { url = "https://files.pythonhosted.org/packages/74/04/891b5c2e9f83589de202e7abaf24cd4e4fa59e1837d64d528829ad6cc107/sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9c6378449e0940476577047150fd09e242529b761dc887c9808a9a937fe990c8", size = 3266054, upload-time = "2025-12-09T22:13:54.262Z" }, - { url = "https://files.pythonhosted.org/packages/f1/24/fc59e7f71b0948cdd4cff7a286210e86b0443ef1d18a23b0d83b87e4b1f7/sqlalchemy-2.0.45-cp313-cp313-win32.whl", hash = "sha256:4b6bec67ca45bc166c8729910bd2a87f1c0407ee955df110d78948f5b5827e8a", size = 2110299, upload-time = "2025-12-09T21:39:33.486Z" }, - { url = "https://files.pythonhosted.org/packages/c0/c5/d17113020b2d43073412aeca09b60d2009442420372123b8d49cc253f8b8/sqlalchemy-2.0.45-cp313-cp313-win_amd64.whl", hash = "sha256:afbf47dc4de31fa38fd491f3705cac5307d21d4bb828a4f020ee59af412744ee", size = 2136264, upload-time = "2025-12-09T21:39:36.801Z" }, - { url = "https://files.pythonhosted.org/packages/3d/8d/bb40a5d10e7a5f2195f235c0b2f2c79b0bf6e8f00c0c223130a4fbd2db09/sqlalchemy-2.0.45-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83d7009f40ce619d483d26ac1b757dfe3167b39921379a8bd1b596cf02dab4a6", size = 3521998, upload-time = "2025-12-09T22:13:28.622Z" }, - { url = "https://files.pythonhosted.org/packages/75/a5/346128b0464886f036c039ea287b7332a410aa2d3fb0bb5d404cb8861635/sqlalchemy-2.0.45-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d8a2ca754e5415cde2b656c27900b19d50ba076aa05ce66e2207623d3fe41f5a", size = 3473434, upload-time = "2025-12-09T22:13:30.188Z" }, - { url = "https://files.pythonhosted.org/packages/cc/64/4e1913772646b060b025d3fc52ce91a58967fe58957df32b455de5a12b4f/sqlalchemy-2.0.45-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f46ec744e7f51275582e6a24326e10c49fbdd3fc99103e01376841213028774", size = 3272404, upload-time = "2025-12-09T22:11:09.662Z" }, - { url = "https://files.pythonhosted.org/packages/b3/27/caf606ee924282fe4747ee4fd454b335a72a6e018f97eab5ff7f28199e16/sqlalchemy-2.0.45-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:883c600c345123c033c2f6caca18def08f1f7f4c3ebeb591a63b6fceffc95cce", size = 3277057, upload-time = "2025-12-09T22:13:56.213Z" }, - { url = "https://files.pythonhosted.org/packages/85/d0/3d64218c9724e91f3d1574d12eb7ff8f19f937643815d8daf792046d88ab/sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2c0b74aa79e2deade948fe8593654c8ef4228c44ba862bb7c9585c8e0db90f33", size = 3222279, upload-time = "2025-12-09T22:11:11.1Z" }, - { url = "https://files.pythonhosted.org/packages/24/10/dd7688a81c5bc7690c2a3764d55a238c524cd1a5a19487928844cb247695/sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a420169cef179d4c9064365f42d779f1e5895ad26ca0c8b4c0233920973db74", size = 3244508, upload-time = "2025-12-09T22:13:57.932Z" }, - { url = "https://files.pythonhosted.org/packages/aa/41/db75756ca49f777e029968d9c9fee338c7907c563267740c6d310a8e3f60/sqlalchemy-2.0.45-cp314-cp314-win32.whl", hash = "sha256:e50dcb81a5dfe4b7b4a4aa8f338116d127cb209559124f3694c70d6cd072b68f", size = 2113204, upload-time = "2025-12-09T21:39:38.365Z" }, - { url = "https://files.pythonhosted.org/packages/89/a2/0e1590e9adb292b1d576dbcf67ff7df8cf55e56e78d2c927686d01080f4b/sqlalchemy-2.0.45-cp314-cp314-win_amd64.whl", hash = "sha256:4748601c8ea959e37e03d13dcda4a44837afcd1b21338e637f7c935b8da06177", size = 2138785, upload-time = "2025-12-09T21:39:39.503Z" }, - { url = "https://files.pythonhosted.org/packages/42/39/f05f0ed54d451156bbed0e23eb0516bcad7cbb9f18b3bf219c786371b3f0/sqlalchemy-2.0.45-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd337d3526ec5298f67d6a30bbbe4ed7e5e68862f0bf6dd21d289f8d37b7d60b", size = 3522029, upload-time = "2025-12-09T22:13:32.09Z" }, - { url = "https://files.pythonhosted.org/packages/54/0f/d15398b98b65c2bce288d5ee3f7d0a81f77ab89d9456994d5c7cc8b2a9db/sqlalchemy-2.0.45-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9a62b446b7d86a3909abbcd1cd3cc550a832f99c2bc37c5b22e1925438b9367b", size = 3475142, upload-time = "2025-12-09T22:13:33.739Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e1/3ccb13c643399d22289c6a9786c1a91e3dcbb68bce4beb44926ac2c557bf/sqlalchemy-2.0.45-py3-none-any.whl", hash = "sha256:5225a288e4c8cc2308dbdd874edad6e7d0fd38eac1e9e5f23503425c8eee20d0", size = 1936672, upload-time = "2025-12-09T21:54:52.608Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/06/aa/9ce0f3e7a9829ead5c8ce549392f33a12c4555a6c0609bb27d882e9c7ddf/sqlalchemy-2.0.46.tar.gz", hash = "sha256:cf36851ee7219c170bb0793dbc3da3e80c582e04a5437bc601bfe8c85c9216d7", size = 9865393, upload-time = "2026-01-21T18:03:45.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/26/66ba59328dc25e523bfcb0f8db48bdebe2035e0159d600e1f01c0fc93967/sqlalchemy-2.0.46-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:895296687ad06dc9b11a024cf68e8d9d3943aa0b4964278d2553b86f1b267735", size = 2155051, upload-time = "2026-01-21T18:27:28.965Z" }, + { url = "https://files.pythonhosted.org/packages/21/cd/9336732941df972fbbfa394db9caa8bb0cf9fe03656ec728d12e9cbd6edc/sqlalchemy-2.0.46-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab65cb2885a9f80f979b85aa4e9c9165a31381ca322cbde7c638fe6eefd1ec39", size = 3234666, upload-time = "2026-01-21T18:32:28.72Z" }, + { url = "https://files.pythonhosted.org/packages/38/62/865ae8b739930ec433cd4123760bee7f8dafdc10abefd725a025604fb0de/sqlalchemy-2.0.46-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:52fe29b3817bd191cc20bad564237c808967972c97fa683c04b28ec8979ae36f", size = 3232917, upload-time = "2026-01-21T18:44:54.064Z" }, + { url = "https://files.pythonhosted.org/packages/24/38/805904b911857f2b5e00fdea44e9570df62110f834378706939825579296/sqlalchemy-2.0.46-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:09168817d6c19954d3b7655da6ba87fcb3a62bb575fb396a81a8b6a9fadfe8b5", size = 3185790, upload-time = "2026-01-21T18:32:30.581Z" }, + { url = "https://files.pythonhosted.org/packages/69/4f/3260bb53aabd2d274856337456ea52f6a7eccf6cce208e558f870cec766b/sqlalchemy-2.0.46-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:be6c0466b4c25b44c5d82b0426b5501de3c424d7a3220e86cd32f319ba56798e", size = 3207206, upload-time = "2026-01-21T18:44:55.93Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b3/67c432d7f9d88bb1a61909b67e29f6354d59186c168fb5d381cf438d3b73/sqlalchemy-2.0.46-cp310-cp310-win32.whl", hash = "sha256:1bc3f601f0a818d27bfe139f6766487d9c88502062a2cd3a7ee6c342e81d5047", size = 2115296, upload-time = "2026-01-21T18:33:12.498Z" }, + { url = "https://files.pythonhosted.org/packages/4a/8c/25fb284f570f9d48e6c240f0269a50cec9cf009a7e08be4c0aaaf0654972/sqlalchemy-2.0.46-cp310-cp310-win_amd64.whl", hash = "sha256:e0c05aff5c6b1bb5fb46a87e0f9d2f733f83ef6cbbbcd5c642b6c01678268061", size = 2138540, upload-time = "2026-01-21T18:33:14.22Z" }, + { url = "https://files.pythonhosted.org/packages/69/ac/b42ad16800d0885105b59380ad69aad0cce5a65276e269ce2729a2343b6a/sqlalchemy-2.0.46-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:261c4b1f101b4a411154f1da2b76497d73abbfc42740029205d4d01fa1052684", size = 2154851, upload-time = "2026-01-21T18:27:30.54Z" }, + { url = "https://files.pythonhosted.org/packages/a0/60/d8710068cb79f64d002ebed62a7263c00c8fd95f4ebd4b5be8f7ca93f2bc/sqlalchemy-2.0.46-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:181903fe8c1b9082995325f1b2e84ac078b1189e2819380c2303a5f90e114a62", size = 3311241, upload-time = "2026-01-21T18:32:33.45Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/20c71487c7219ab3aa7421c7c62d93824c97c1460f2e8bb72404b0192d13/sqlalchemy-2.0.46-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:590be24e20e2424a4c3c1b0835e9405fa3d0af5823a1a9fc02e5dff56471515f", size = 3310741, upload-time = "2026-01-21T18:44:57.887Z" }, + { url = "https://files.pythonhosted.org/packages/65/80/d26d00b3b249ae000eee4db206fcfc564bf6ca5030e4747adf451f4b5108/sqlalchemy-2.0.46-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7568fe771f974abadce52669ef3a03150ff03186d8eb82613bc8adc435a03f01", size = 3263116, upload-time = "2026-01-21T18:32:35.044Z" }, + { url = "https://files.pythonhosted.org/packages/da/ee/74dda7506640923821340541e8e45bd3edd8df78664f1f2e0aae8077192b/sqlalchemy-2.0.46-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf7e1e78af38047e08836d33502c7a278915698b7c2145d045f780201679999", size = 3285327, upload-time = "2026-01-21T18:44:59.254Z" }, + { url = "https://files.pythonhosted.org/packages/9f/25/6dcf8abafff1389a21c7185364de145107b7394ecdcb05233815b236330d/sqlalchemy-2.0.46-cp311-cp311-win32.whl", hash = "sha256:9d80ea2ac519c364a7286e8d765d6cd08648f5b21ca855a8017d9871f075542d", size = 2114564, upload-time = "2026-01-21T18:33:15.85Z" }, + { url = "https://files.pythonhosted.org/packages/93/5f/e081490f8523adc0088f777e4ebad3cac21e498ec8a3d4067074e21447a1/sqlalchemy-2.0.46-cp311-cp311-win_amd64.whl", hash = "sha256:585af6afe518732d9ccd3aea33af2edaae4a7aa881af5d8f6f4fe3a368699597", size = 2139233, upload-time = "2026-01-21T18:33:17.528Z" }, + { url = "https://files.pythonhosted.org/packages/b6/35/d16bfa235c8b7caba3730bba43e20b1e376d2224f407c178fbf59559f23e/sqlalchemy-2.0.46-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3a9a72b0da8387f15d5810f1facca8f879de9b85af8c645138cba61ea147968c", size = 2153405, upload-time = "2026-01-21T19:05:54.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/6c/3192e24486749862f495ddc6584ed730c0c994a67550ec395d872a2ad650/sqlalchemy-2.0.46-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2347c3f0efc4de367ba00218e0ae5c4ba2306e47216ef80d6e31761ac97cb0b9", size = 3334702, upload-time = "2026-01-21T18:46:45.384Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a2/b9f33c8d68a3747d972a0bb758c6b63691f8fb8a49014bc3379ba15d4274/sqlalchemy-2.0.46-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9094c8b3197db12aa6f05c51c05daaad0a92b8c9af5388569847b03b1007fb1b", size = 3347664, upload-time = "2026-01-21T18:40:09.979Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d2/3e59e2a91eaec9db7e8dc6b37b91489b5caeb054f670f32c95bcba98940f/sqlalchemy-2.0.46-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37fee2164cf21417478b6a906adc1a91d69ae9aba8f9533e67ce882f4bb1de53", size = 3277372, upload-time = "2026-01-21T18:46:47.168Z" }, + { url = "https://files.pythonhosted.org/packages/dd/dd/67bc2e368b524e2192c3927b423798deda72c003e73a1e94c21e74b20a85/sqlalchemy-2.0.46-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b1e14b2f6965a685c7128bd315e27387205429c2e339eeec55cb75ca4ab0ea2e", size = 3312425, upload-time = "2026-01-21T18:40:11.548Z" }, + { url = "https://files.pythonhosted.org/packages/43/82/0ecd68e172bfe62247e96cb47867c2d68752566811a4e8c9d8f6e7c38a65/sqlalchemy-2.0.46-cp312-cp312-win32.whl", hash = "sha256:412f26bb4ba942d52016edc8d12fb15d91d3cd46b0047ba46e424213ad407bcb", size = 2113155, upload-time = "2026-01-21T18:42:49.748Z" }, + { url = "https://files.pythonhosted.org/packages/bc/2a/2821a45742073fc0331dc132552b30de68ba9563230853437cac54b2b53e/sqlalchemy-2.0.46-cp312-cp312-win_amd64.whl", hash = "sha256:ea3cd46b6713a10216323cda3333514944e510aa691c945334713fca6b5279ff", size = 2140078, upload-time = "2026-01-21T18:42:51.197Z" }, + { url = "https://files.pythonhosted.org/packages/b3/4b/fa7838fe20bb752810feed60e45625a9a8b0102c0c09971e2d1d95362992/sqlalchemy-2.0.46-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:93a12da97cca70cea10d4b4fc602589c4511f96c1f8f6c11817620c021d21d00", size = 2150268, upload-time = "2026-01-21T19:05:56.621Z" }, + { url = "https://files.pythonhosted.org/packages/46/c1/b34dccd712e8ea846edf396e00973dda82d598cb93762e55e43e6835eba9/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af865c18752d416798dae13f83f38927c52f085c52e2f32b8ab0fef46fdd02c2", size = 3276511, upload-time = "2026-01-21T18:46:49.022Z" }, + { url = "https://files.pythonhosted.org/packages/96/48/a04d9c94753e5d5d096c628c82a98c4793b9c08ca0e7155c3eb7d7db9f24/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8d679b5f318423eacb61f933a9a0f75535bfca7056daeadbf6bd5bcee6183aee", size = 3292881, upload-time = "2026-01-21T18:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/be/f4/06eda6e91476f90a7d8058f74311cb65a2fb68d988171aced81707189131/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64901e08c33462acc9ec3bad27fc7a5c2b6491665f2aa57564e57a4f5d7c52ad", size = 3224559, upload-time = "2026-01-21T18:46:50.974Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a2/d2af04095412ca6345ac22b33b89fe8d6f32a481e613ffcb2377d931d8d0/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8ac45e8f4eaac0f9f8043ea0e224158855c6a4329fd4ee37c45c61e3beb518e", size = 3262728, upload-time = "2026-01-21T18:40:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/31/48/1980c7caa5978a3b8225b4d230e69a2a6538a3562b8b31cea679b6933c83/sqlalchemy-2.0.46-cp313-cp313-win32.whl", hash = "sha256:8d3b44b3d0ab2f1319d71d9863d76eeb46766f8cf9e921ac293511804d39813f", size = 2111295, upload-time = "2026-01-21T18:42:52.366Z" }, + { url = "https://files.pythonhosted.org/packages/2d/54/f8d65bbde3d877617c4720f3c9f60e99bb7266df0d5d78b6e25e7c149f35/sqlalchemy-2.0.46-cp313-cp313-win_amd64.whl", hash = "sha256:77f8071d8fbcbb2dd11b7fd40dedd04e8ebe2eb80497916efedba844298065ef", size = 2137076, upload-time = "2026-01-21T18:42:53.924Z" }, + { url = "https://files.pythonhosted.org/packages/56/ba/9be4f97c7eb2b9d5544f2624adfc2853e796ed51d2bb8aec90bc94b7137e/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1e8cc6cc01da346dc92d9509a63033b9b1bda4fed7a7a7807ed385c7dccdc10", size = 3556533, upload-time = "2026-01-21T18:33:06.636Z" }, + { url = "https://files.pythonhosted.org/packages/20/a6/b1fc6634564dbb4415b7ed6419cdfeaadefd2c39cdab1e3aa07a5f2474c2/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96c7cca1a4babaaf3bfff3e4e606e38578856917e52f0384635a95b226c87764", size = 3523208, upload-time = "2026-01-21T18:45:08.436Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d8/41e0bdfc0f930ff236f86fccd12962d8fa03713f17ed57332d38af6a3782/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b2a9f9aee38039cf4755891a1e50e1effcc42ea6ba053743f452c372c3152b1b", size = 3464292, upload-time = "2026-01-21T18:33:08.208Z" }, + { url = "https://files.pythonhosted.org/packages/f0/8b/9dcbec62d95bea85f5ecad9b8d65b78cc30fb0ffceeb3597961f3712549b/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:db23b1bf8cfe1f7fda19018e7207b20cdb5168f83c437ff7e95d19e39289c447", size = 3473497, upload-time = "2026-01-21T18:45:10.552Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f8/5ecdfc73383ec496de038ed1614de9e740a82db9ad67e6e4514ebc0708a3/sqlalchemy-2.0.46-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:56bdd261bfd0895452006d5316cbf35739c53b9bb71a170a331fa0ea560b2ada", size = 2152079, upload-time = "2026-01-21T19:05:58.477Z" }, + { url = "https://files.pythonhosted.org/packages/e5/bf/eba3036be7663ce4d9c050bc3d63794dc29fbe01691f2bf5ccb64e048d20/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33e462154edb9493f6c3ad2125931e273bbd0be8ae53f3ecd1c161ea9a1dd366", size = 3272216, upload-time = "2026-01-21T18:46:52.634Z" }, + { url = "https://files.pythonhosted.org/packages/05/45/1256fb597bb83b58a01ddb600c59fe6fdf0e5afe333f0456ed75c0f8d7bd/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bcdce05f056622a632f1d44bb47dbdb677f58cad393612280406ce37530eb6d", size = 3277208, upload-time = "2026-01-21T18:40:16.38Z" }, + { url = "https://files.pythonhosted.org/packages/d9/a0/2053b39e4e63b5d7ceb3372cface0859a067c1ddbd575ea7e9985716f771/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e84b09a9b0f19accedcbeff5c2caf36e0dd537341a33aad8d680336152dc34e", size = 3221994, upload-time = "2026-01-21T18:46:54.622Z" }, + { url = "https://files.pythonhosted.org/packages/1e/87/97713497d9502553c68f105a1cb62786ba1ee91dea3852ae4067ed956a50/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4f52f7291a92381e9b4de9050b0a65ce5d6a763333406861e33906b8aa4906bf", size = 3243990, upload-time = "2026-01-21T18:40:18.253Z" }, + { url = "https://files.pythonhosted.org/packages/a8/87/5d1b23548f420ff823c236f8bea36b1a997250fd2f892e44a3838ca424f4/sqlalchemy-2.0.46-cp314-cp314-win32.whl", hash = "sha256:70ed2830b169a9960193f4d4322d22be5c0925357d82cbf485b3369893350908", size = 2114215, upload-time = "2026-01-21T18:42:55.232Z" }, + { url = "https://files.pythonhosted.org/packages/3a/20/555f39cbcf0c10cf452988b6a93c2a12495035f68b3dbd1a408531049d31/sqlalchemy-2.0.46-cp314-cp314-win_amd64.whl", hash = "sha256:3c32e993bc57be6d177f7d5d31edb93f30726d798ad86ff9066d75d9bf2e0b6b", size = 2139867, upload-time = "2026-01-21T18:42:56.474Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f0/f96c8057c982d9d8a7a68f45d69c674bc6f78cad401099692fe16521640a/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4dafb537740eef640c4d6a7c254611dca2df87eaf6d14d6a5fca9d1f4c3fc0fa", size = 3561202, upload-time = "2026-01-21T18:33:10.337Z" }, + { url = "https://files.pythonhosted.org/packages/d7/53/3b37dda0a5b137f21ef608d8dfc77b08477bab0fe2ac9d3e0a66eaeab6fc/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42a1643dc5427b69aca967dae540a90b0fbf57eaf248f13a90ea5930e0966863", size = 3526296, upload-time = "2026-01-21T18:45:12.657Z" }, + { url = "https://files.pythonhosted.org/packages/33/75/f28622ba6dde79cd545055ea7bd4062dc934e0621f7b3be2891f8563f8de/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ff33c6e6ad006bbc0f34f5faf941cfc62c45841c64c0a058ac38c799f15b5ede", size = 3470008, upload-time = "2026-01-21T18:33:11.725Z" }, + { url = "https://files.pythonhosted.org/packages/a9/42/4afecbbc38d5e99b18acef446453c76eec6fbd03db0a457a12a056836e22/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:82ec52100ec1e6ec671563bbd02d7c7c8d0b9e71a0723c72f22ecf52d1755330", size = 3476137, upload-time = "2026-01-21T18:45:15.001Z" }, + { url = "https://files.pythonhosted.org/packages/fc/a1/9c4efa03300926601c19c18582531b45aededfb961ab3c3585f1e24f120b/sqlalchemy-2.0.46-py3-none-any.whl", hash = "sha256:f9c11766e7e7c0a2767dda5acb006a118640c9fc0a4104214b96269bfb78399e", size = 1937882, upload-time = "2026-01-21T18:22:10.456Z" }, ] [[package]] From 00db217fd0ab1d0fb82a267822d14c5a369f0c7b Mon Sep 17 00:00:00 2001 From: Nan Yu Date: Mon, 26 Jan 2026 23:15:43 +0000 Subject: [PATCH 2/6] feat: Add a A2uiValidator to validate loaded examples --- a2a_agents/python/a2ui_agent/pyproject.toml | 2 +- .../src/a2ui/inference/inference_strategy.py | 2 + .../src/a2ui/inference/schema/catalog.py | 26 +- .../src/a2ui/inference/schema/constants.py | 1 + .../src/a2ui/inference/schema/manager.py | 9 +- .../src/a2ui/inference/schema/validator.py | 179 ++++++ .../src/a2ui/inference/template/manager.py | 1 + .../tests/inference/test_catalog.py | 12 +- .../tests/inference/test_schema_manager.py | 2 +- .../tests/inference/test_validator.py | 538 ++++++++++++++++ .../tests/integration/verify_load_real.py | 589 +++++++++++++++++- 11 files changed, 1339 insertions(+), 22 deletions(-) create mode 100644 a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/validator.py create mode 100644 a2a_agents/python/a2ui_agent/tests/inference/test_validator.py diff --git a/a2a_agents/python/a2ui_agent/pyproject.toml b/a2a_agents/python/a2ui_agent/pyproject.toml index f8e3c9fd5..960a031dd 100644 --- a/a2a_agents/python/a2ui_agent/pyproject.toml +++ b/a2a_agents/python/a2ui_agent/pyproject.toml @@ -12,7 +12,7 @@ dependencies = [ ] [build-system] -requires = ["hatchling"] +requires = ["hatchling", "jsonschema"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] diff --git a/a2a_agents/python/a2ui_agent/src/a2ui/inference/inference_strategy.py b/a2a_agents/python/a2ui_agent/src/a2ui/inference/inference_strategy.py index ed1b0789f..f74db8585 100644 --- a/a2a_agents/python/a2ui_agent/src/a2ui/inference/inference_strategy.py +++ b/a2a_agents/python/a2ui_agent/src/a2ui/inference/inference_strategy.py @@ -28,6 +28,7 @@ def generate_system_prompt( allowed_components: List[str] = [], include_schema: bool = False, include_examples: bool = False, + validate_examples: bool = False, ) -> str: """ Generates a system prompt for all LLM requests. @@ -40,6 +41,7 @@ def generate_system_prompt( allowed_components: List of allowed components. include_schema: Whether to include the schema. include_examples: Whether to include examples. + validate_examples: Whether to validate examples. Returns: The system prompt. diff --git a/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/catalog.py b/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/catalog.py index 0a53f1a1a..c187738bd 100644 --- a/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/catalog.py +++ b/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/catalog.py @@ -16,12 +16,15 @@ import json import logging import os -from dataclasses import dataclass, replace -from typing import Any, Dict, List, Optional +from dataclasses import dataclass, field, replace +from typing import Any, Dict, List, Optional, TYPE_CHECKING from .constants import CATALOG_COMPONENTS_KEY, CATALOG_ID_KEY from referencing import Registry, Resource +if TYPE_CHECKING: + from .validator import A2uiValidator + @dataclass class CustomCatalogConfig: @@ -102,6 +105,12 @@ def with_pruned_components(self, allowed_components: List[str]) -> "A2uiCatalog" return replace(self, catalog_schema=schema_copy) + @property + def validator(self) -> "A2uiValidator": + from .validator import A2uiValidator + + return A2uiValidator(self) + def render_as_llm_instructions(self) -> str: """Renders the catalog and schema as LLM instructions.""" all_schemas = [] @@ -123,7 +132,7 @@ def render_as_llm_instructions(self) -> str: return "\n\n".join(all_schemas) - def load_examples(self, path: Optional[str]) -> str: + def load_examples(self, path: Optional[str], validate: bool = False) -> str: """Loads and validates examples from a directory.""" if not path or not os.path.isdir(path): if path: @@ -138,6 +147,8 @@ def load_examples(self, path: Optional[str]) -> str: try: with open(full_path, "r", encoding="utf-8") as f: content = f.read() + if validate and not self._validate_example(full_path, basename, content): + continue merged_examples.append( f"---BEGIN {basename}---\n{content}\n---END {basename}---" ) @@ -253,3 +264,12 @@ def merge_into(target: Dict[str, Any], source: Dict[str, Any]): target["oneOf"] = new_one_of return result + + def _validate_example(self, full_path: str, basename: str, content: str) -> bool: + try: + json_data = json.loads(content) + self.validator.validate(json_data) + except Exception as e: + logging.warning(f"Failed to validate example {full_path}: {e}") + return False + return True diff --git a/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/constants.py b/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/constants.py index 8a8682090..e3def1e13 100644 --- a/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/constants.py +++ b/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/constants.py @@ -20,6 +20,7 @@ CATALOG_SCHEMA_KEY = "catalog" CATALOG_COMPONENTS_KEY = "components" CATALOG_ID_KEY = "catalogId" +CATALOG_STYLES_KEY = "styles" BASE_SCHEMA_URL = "https://a2ui.org/" BASIC_CATALOG_NAME = "basic" diff --git a/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/manager.py b/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/manager.py index 4548a10f1..10018c05b 100644 --- a/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/manager.py +++ b/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/manager.py @@ -283,10 +283,12 @@ def get_effective_catalog( pruned_catalog = catalog.with_pruned_components(allowed_components) return pruned_catalog - def load_examples(self, catalog: A2uiCatalog) -> str: + def load_examples(self, catalog: A2uiCatalog, validate: bool = False) -> str: """Loads examples for a catalog.""" if catalog.catalog_id in self._catalog_example_paths: - return catalog.load_examples(self._catalog_example_paths[catalog.catalog_id]) + return catalog.load_examples( + self._catalog_example_paths[catalog.catalog_id], validate=validate + ) return "" def generate_system_prompt( @@ -298,6 +300,7 @@ def generate_system_prompt( allowed_components: List[str] = [], include_schema: bool = False, include_examples: bool = False, + validate_examples: bool = False, ) -> str: """Assembles the final system instruction for the LLM.""" parts = [role_description] @@ -314,7 +317,7 @@ def generate_system_prompt( parts.append(final_catalog.render_as_llm_instructions()) if include_examples: - examples_str = self.load_examples(final_catalog) + examples_str = self.load_examples(final_catalog, validate=validate_examples) if examples_str: parts.append(f"### Examples:\n{examples_str}") diff --git a/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/validator.py b/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/validator.py new file mode 100644 index 000000000..a42b2052e --- /dev/null +++ b/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/validator.py @@ -0,0 +1,179 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import copy +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple + +from jsonschema import Draft202012Validator + +if TYPE_CHECKING: + from .catalog import A2uiCatalog + +from .constants import ( + BASE_SCHEMA_URL, + CATALOG_COMPONENTS_KEY, + CATALOG_ID_KEY, + CATALOG_STYLES_KEY, +) + + +def _inject_additional_properties( + schema: Dict[str, Any], + source_properties: Dict[str, Any], + mapping: Dict[str, str] = None, +) -> Tuple[Dict[str, Any], Set[str]]: + """ + Recursively injects properties from source_properties into nodes with additionalProperties=True and sets additionalProperties=False. + + Args: + schema: The target schema to traverse and patch. + source_properties: A dictionary of top-level property groups (e.g., "components", "styles") from the source schema. + + Returns: + A tuple containing: + - The patched schema. + - A set of keys from source_properties that were injected. + """ + injected_keys = set() + + def recursive_inject(obj): + if isinstance(obj, dict): + new_obj = {} + for k, v in obj.items(): + # If this node has additionalProperties=True, we inject the source properties + if isinstance(v, dict) and v.get("additionalProperties") is True: + if k in source_properties: + injected_keys.add(k) + new_node = dict(v) + new_node["additionalProperties"] = False + new_node["properties"] = { + **new_node.get("properties", {}), + **source_properties[k], + } + new_obj[k] = new_node + else: # No matching source group, keep as is but recurse children + new_obj[k] = recursive_inject(v) + else: # Not a node with additionalProperties, recurse children + new_obj[k] = recursive_inject(v) + return new_obj + elif isinstance(obj, list): + return [recursive_inject(i) for i in obj] + return obj + + return recursive_inject(schema), injected_keys + + +# LLM is instructed to generate a list of messages, so we wrap the bundled schema in an array. +def _wrap_main_schema(schema: Dict[str, Any]) -> Dict[str, Any]: + return {"type": "array", "items": schema} + + +class A2uiValidator: + """Validator for A2UI messages.""" + + def __init__(self, catalog: "A2uiCatalog"): + self._catalog = catalog + self._validator = self._build_validator() + + def _build_validator(self) -> Draft202012Validator: + """Builds a validator for the A2UI schema.""" + + if self._catalog.version == "0.8": + return self._build_0_8_validator() + return self._build_0_9_validator() + + def _bundle_0_8_schemas(self) -> Dict[str, Any]: + if not self._catalog.s2c_schema: + return {} + + bundled = copy.deepcopy(self._catalog.s2c_schema) + + # Prepare catalog components and styles for injection + source_properties = {} + catalog_schema = self._catalog.catalog_schema + if catalog_schema: + if CATALOG_COMPONENTS_KEY in catalog_schema: + # Special mapping for v0.8: "components" -> "component" + source_properties["component"] = catalog_schema[CATALOG_COMPONENTS_KEY] + if CATALOG_STYLES_KEY in catalog_schema: + source_properties[CATALOG_STYLES_KEY] = catalog_schema[CATALOG_STYLES_KEY] + + bundled, _ = _inject_additional_properties(bundled, source_properties) + return bundled + + def _build_0_8_validator(self) -> Draft202012Validator: + """Builds a validator for the A2UI schema version 0.8.""" + bundled_schema = self._bundle_0_8_schemas() + full_schema = _wrap_main_schema(bundled_schema) + return Draft202012Validator(full_schema) + + def _build_0_9_validator(self) -> Draft202012Validator: + """Builds a validator for the A2UI schema version 0.9+.""" + full_schema = _wrap_main_schema(self._catalog.s2c_schema) + + from referencing import Registry, Resource + + # v0.9 schemas (e.g. server_to_client.json) use relative references like + # 'catalog.json#/$defs/anyComponent'. Since server_to_client.json has + # $id: https://a2ui.org/specification/v0_9/server_to_client.json, + # these resolve to https://a2ui.org/specification/v0_9/catalog.json. + # We must register them using these absolute URIs. + base_uri = self._catalog.s2c_schema.get("$id", BASE_SCHEMA_URL) + import os + + def get_sibling_uri(uri, filename): + return os.path.join(os.path.dirname(uri), filename) + + catalog_uri = get_sibling_uri(base_uri, "catalog.json") + common_types_uri = get_sibling_uri(base_uri, "common_types.json") + + resources = [ + ( + common_types_uri, + Resource.from_contents(self._catalog.common_types_schema), + ), + ( + catalog_uri, + Resource.from_contents(self._catalog.catalog_schema), + ), + # Fallbacks for robustness + ("catalog.json", Resource.from_contents(self._catalog.catalog_schema)), + ( + "common_types.json", + Resource.from_contents(self._catalog.common_types_schema), + ), + ] + # Also register the catalog ID if it's different from the catalog URI + if self._catalog.catalog_id and self._catalog.catalog_id != catalog_uri: + resources.append(( + self._catalog.catalog_id, + Resource.from_contents(self._catalog.catalog_schema), + )) + + registry = Registry().with_resources(resources) + validator_schema = copy.deepcopy(full_schema) + validator_schema["$schema"] = "https://json-schema.org/draft/2020-12/schema" + + return Draft202012Validator(validator_schema, registry=registry) + + def validate(self, message: Dict[str, Any]) -> None: + """Validates an A2UI message against the schema.""" + error = next(self._validator.iter_errors(message), None) + if error is not None: + msg = f"Validation failed: {error.message}" + if error.context: + msg += "\nContext failures:" + for sub_error in error.context: + msg += f"\n - {sub_error.message}" + raise ValueError(msg) diff --git a/a2a_agents/python/a2ui_agent/src/a2ui/inference/template/manager.py b/a2a_agents/python/a2ui_agent/src/a2ui/inference/template/manager.py index cab2ee5a6..37e24f894 100644 --- a/a2a_agents/python/a2ui_agent/src/a2ui/inference/template/manager.py +++ b/a2a_agents/python/a2ui_agent/src/a2ui/inference/template/manager.py @@ -27,6 +27,7 @@ def generate_system_prompt( allowed_components: List[str] = [], include_schema: bool = False, include_examples: bool = False, + validate_examples: bool = False, ) -> str: # TODO: Implementation logic for Template Manager return "" diff --git a/a2a_agents/python/a2ui_agent/tests/inference/test_catalog.py b/a2a_agents/python/a2ui_agent/tests/inference/test_catalog.py index 89874db82..f0cf9a7fa 100644 --- a/a2a_agents/python/a2ui_agent/tests/inference/test_catalog.py +++ b/a2a_agents/python/a2ui_agent/tests/inference/test_catalog.py @@ -49,8 +49,12 @@ def test_catalog_id_missing_raises_error(): def test_load_examples(tmp_path): example_dir = tmp_path / "examples" example_dir.mkdir() - (example_dir / "example1.json").write_text('{"foo": "bar"}') - (example_dir / "example2.json").write_text('{"baz": "qux"}') + (example_dir / "example1.json").write_text( + '[{"beginRendering": {"surfaceId": "id"}}]' + ) + (example_dir / "example2.json").write_text( + '[{"beginRendering": {"surfaceId": "id"}}]' + ) (example_dir / "ignored.txt").write_text("should not be loaded") catalog = A2uiCatalog( @@ -63,9 +67,9 @@ def test_load_examples(tmp_path): examples_str = catalog.load_examples(str(example_dir)) assert "---BEGIN example1---" in examples_str - assert '{"foo": "bar"}' in examples_str + assert '[{"beginRendering": {"surfaceId": "id"}}]' in examples_str assert "---BEGIN example2---" in examples_str - assert '{"baz": "qux"}' in examples_str + assert '[{"beginRendering": {"surfaceId": "id"}}]' in examples_str assert "ignored" not in examples_str diff --git a/a2a_agents/python/a2ui_agent/tests/inference/test_schema_manager.py b/a2a_agents/python/a2ui_agent/tests/inference/test_schema_manager.py index 354286290..03b1545d3 100644 --- a/a2a_agents/python/a2ui_agent/tests/inference/test_schema_manager.py +++ b/a2a_agents/python/a2ui_agent/tests/inference/test_schema_manager.py @@ -218,7 +218,7 @@ def joinpath_side_effect(path): return_value="---BEGIN example1---\n{}\n---END example1---", ): prompt = manager.generate_system_prompt("Role description", include_examples=True) - assert "### Examples:" in prompt + assert "### Examples" in prompt assert "example1" in prompt # Test without examples diff --git a/a2a_agents/python/a2ui_agent/tests/inference/test_validator.py b/a2a_agents/python/a2ui_agent/tests/inference/test_validator.py new file mode 100644 index 000000000..72f56425d --- /dev/null +++ b/a2a_agents/python/a2ui_agent/tests/inference/test_validator.py @@ -0,0 +1,538 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import copy +import pytest +from unittest.mock import MagicMock +from a2ui.inference.schema.manager import A2uiSchemaManager, A2uiCatalog, CustomCatalogConfig + + +class TestValidator: + + @pytest.fixture + def catalog_0_9(self): + s2c_schema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://a2ui.org/specification/v0_9/server_to_client.json", + "title": "A2UI Message Schema", + "oneOf": [ + {"$ref": "#/$defs/CreateSurfaceMessage"}, + {"$ref": "#/$defs/UpdateComponentsMessage"}, + ], + "$defs": { + "CreateSurfaceMessage": { + "type": "object", + "properties": { + "version": {"const": "v0.9"}, + "createSurface": { + "type": "object", + "properties": { + "surfaceId": { + "type": "string", + }, + "catalogId": { + "type": "string", + }, + "theme": {"type": "object", "additionalProperties": True}, + }, + "required": ["surfaceId", "catalogId"], + "additionalProperties": False, + }, + }, + "required": ["version", "createSurface"], + "additionalProperties": False, + }, + "UpdateComponentsMessage": { + "type": "object", + "properties": { + "version": {"const": "v0.9"}, + "updateComponents": { + "type": "object", + "properties": { + "surfaceId": { + "type": "string", + }, + "components": { + "type": "array", + "minItems": 1, + "items": {"$ref": "catalog.json#/$defs/anyComponent"}, + }, + }, + "required": ["surfaceId", "components"], + "additionalProperties": False, + }, + }, + "required": ["version", "updateComponents"], + "additionalProperties": False, + }, + "UpdateDataModelMessage": { + "type": "object", + "properties": { + "version": {"const": "v0.9"}, + "updateDataModel": { + "type": "object", + "properties": { + "surfaceId": { + "type": "string", + }, + "value": {"additionalProperties": True}, + }, + "required": ["surfaceId"], + "additionalProperties": False, + }, + }, + "required": ["version", "updateDataModel"], + "additionalProperties": False, + }, + }, + } + catalog_schema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://a2ui.org/specification/v0_9/standard_catalog.json", + "title": "A2UI Standard Catalog", + "catalogId": "https://a2ui.dev/specification/v0_9/standard_catalog.json", + "components": { + "Text": { + "type": "object", + "allOf": [ + {"$ref": "common_types.json#/$defs/ComponentCommon"}, + {"$ref": "#/$defs/CatalogComponentCommon"}, + { + "type": "object", + "properties": { + "component": {"const": "Text"}, + "text": {"$ref": "common_types.json#/$defs/DynamicString"}, + "variant": { + "type": "string", + "enum": [ + "h1", + "h2", + "h3", + "h4", + "h5", + "caption", + "body", + ], + }, + }, + "required": ["component", "text"], + }, + ], + }, + "Image": {}, + "Icon": {}, + }, + "theme": {"primaryColor": {"type": "string"}, "iconUrl": {"type": "string"}}, + "$defs": { + "CatalogComponentCommon": { + "type": "object", + "properties": {"weight": {"type": "number"}}, + }, + "anyComponent": { + "oneOf": [ + {"$ref": "#/components/Text"}, + {"$ref": "#/components/Image"}, + {"$ref": "#/components/Icon"}, + ], + "discriminator": {"propertyName": "component"}, + }, + }, + } + common_types_schema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://a2ui.org/specification/v0_9/common_types.json", + "title": "A2UI Common Types", + "$defs": { + "ComponentId": { + "type": "string", + }, + "AccessibilityAttributes": { + "type": "object", + "properties": { + "label": { + "$ref": "#/$defs/DynamicString", + } + }, + }, + "ComponentCommon": { + "type": "object", + "properties": {"id": {"$ref": "#/$defs/ComponentId"}}, + "required": ["id"], + }, + "DataBinding": {"type": "object"}, + "DynamicString": { + "anyOf": [{"type": "string"}, {"$ref": "#/$defs/DataBinding"}] + }, + "DynamicValue": { + "anyOf": [ + {"type": "object"}, + {"type": "array"}, + {"$ref": "#/$defs/DataBinding"}, + ] + }, + "DynamicNumber": { + "anyOf": [{"type": "number"}, {"$ref": "#/$defs/DataBinding"}] + }, + "ChildList": { + "oneOf": [ + {"type": "array", "items": {"type": "string"}}, + {"$ref": "#/$defs/DataBinding"}, + ] + }, + }, + } + return A2uiCatalog( + version="0.9", + name="standard", + catalog_schema=catalog_schema, + s2c_schema=s2c_schema, + common_types_schema=common_types_schema, + ) + + @pytest.fixture + def catalog_0_8(self): + s2c_schema = { + "title": "A2UI Message Schema", + "description": "Describes a JSON payload for an A2UI message.", + "type": "object", + "additionalProperties": False, + "properties": { + "beginRendering": { + "type": "object", + "additionalProperties": False, + "properties": { + "surfaceId": {"type": "string"}, + "styles": { + "type": "object", + "description": "Styling information for the UI.", + "additionalProperties": True, + }, + }, + "required": ["surfaceId"], + }, + "surfaceUpdate": { + "type": "object", + "additionalProperties": False, + "properties": { + "surfaceId": { + "type": "string", + }, + "components": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": False, + "properties": { + "id": { + "type": "string", + }, + "component": { + "type": "object", + "description": "A wrapper object.", + "additionalProperties": True, + }, + }, + "required": ["id", "component"], + }, + }, + }, + }, + "required": ["surfaceId", "components"], + }, + } + catalog_schema = { + "catalogId": ( + "https://a2ui.org/specification/v0_8/json/standard_catalog_definition.json" + ), + "components": {"Text": {"type": "object"}, "Button": {"type": "object"}}, + "styles": {"font": {"type": "string"}, "primaryColor": {"type": "string"}}, + } + return A2uiCatalog( + version="0.8", + name="standard", + catalog_schema=catalog_schema, + s2c_schema=s2c_schema, + common_types_schema=None, + ) + + def test_validator_0_9(self, catalog_0_9): + # v0.9+ uses Registry and referencing, not monolithic bundling. + # We test by validating a sample message. + message = [{ + "version": "v0.9", + "createSurface": { + "surfaceId": "test-id", + "catalogId": "standard", + "theme": {"primaryColor": "blue", "iconUrl": "http://img"}, + }, + }] + # Should not raise exception + catalog_0_9.validator.validate(message) + + # Test failure: version is missing + invalid_message = [{"createSurface": {"surfaceId": "123", "catalogId": "standard"}}] + # Note: version is missing in the message object + with pytest.raises(ValueError) as excinfo: + catalog_0_9.validator.validate(invalid_message) + assert "'version' is a required property" in str(excinfo.value) + + # Test failure: wrong version const + invalid_message = [{ + "version": "0.9", + "createSurface": {"surfaceId": "123", "catalogId": "standard"}, + }] + with pytest.raises(ValueError) as excinfo: + catalog_0_9.validator.validate(invalid_message) + assert "'v0.9' was expected" in str(excinfo.value) + + # Test failure: surfaceId must be string + invalid_message = [{ + "version": "v0.9", + "createSurface": {"surfaceId": 123, "catalogId": "standard"}, + }] + with pytest.raises(ValueError) as excinfo: + catalog_0_9.validator.validate(invalid_message) + assert "123 is not of type 'string'" in str(excinfo.value) + + # Test failure: catalogId is missing + invalid_message = [{"version": "v0.9", "createSurface": {"surfaceId": "123"}}] + with pytest.raises(ValueError) as excinfo: + catalog_0_9.validator.validate(invalid_message) + assert "'catalogId' is a required property" in str(excinfo.value) + + def test_validator_0_8(self, catalog_0_8): + # v0.8 uses monolithic bundling for validation + message = [{ + "beginRendering": { + "surfaceId": "test-id", + "styles": {"primaryColor": "#ff0000"}, + } + }] + # Should not raise exception + catalog_0_8.validator.validate(message) + + # Test failure: surfaceId must be string + invalid_message = [{"beginRendering": {"surfaceId": 123}}] + with pytest.raises(ValueError) as excinfo: + catalog_0_8.validator.validate(invalid_message) + assert "123 is not of type 'string'" in str(excinfo.value) + + # Test failure: styles must be object + invalid_message = [ + {"beginRendering": {"surfaceId": "id", "styles": "not-an-object"}} + ] + with pytest.raises(ValueError) as excinfo: + catalog_0_8.validator.validate(invalid_message) + assert "'not-an-object' is not of type 'object'" in str(excinfo.value) + + def test_custom_catalog_0_8(self, catalog_0_8): + """Tests validation with a custom catalog in v0.8.""" + custom_components = { + "Canvas": { + "type": "object", + "properties": { + "children": { + "type": "object", + "properties": { + "explicitList": {"type": "array", "items": {"type": "string"}} + }, + "required": ["explicitList"], + } + }, + "required": ["children"], + }, + "Chart": { + "type": "object", + "properties": { + "type": {"type": "string", "enum": ["doughnut", "pie"]}, + "title": { + "type": "object", + "properties": { + "literalString": {"type": "string"}, + "path": {"type": "string"}, + }, + }, + "chartData": { + "type": "object", + "properties": { + "literalArray": {"type": "array"}, + "path": {"type": "string"}, + }, + }, + }, + "required": ["type", "chartData"], + }, + "GoogleMap": { + "type": "object", + "properties": { + "center": { + "type": "object", + "properties": { + "literalObject": {"type": "object"}, + "path": {"type": "string"}, + }, + }, + "zoom": { + "type": "object", + "properties": { + "literalNumber": {"type": "number"}, + "path": {"type": "string"}, + }, + }, + }, + "required": ["center", "zoom"], + }, + } + + # Create a new catalog with these components + catalog_schema = copy.deepcopy(catalog_0_8.catalog_schema) + catalog_schema["components"] = custom_components + + custom_catalog = A2uiCatalog( + version="0.8", + name="custom", + catalog_schema=catalog_schema, + s2c_schema=catalog_0_8.s2c_schema, + common_types_schema=None, + ) + + # Valid message + message = [{ + "surfaceUpdate": { + "surfaceId": "id1", + "components": [ + { + "id": "c1", + "component": {"Canvas": {"children": {"explicitList": ["item1"]}}}, + }, + { + "id": "c2", + "component": { + "Chart": {"type": "pie", "chartData": {"path": "/data"}} + }, + }, + ], + } + }] + custom_catalog.validator.validate(message) + + def test_custom_catalog_0_9(self, catalog_0_9): + """Tests validation with a custom catalog in v0.9.""" + # Use the existing catalog_0_9 fixture but override its catalog_schema + # to include the custom components. + custom_components = { + "Canvas": { + "type": "object", + "allOf": [ + {"$ref": "common_types.json#/$defs/ComponentCommon"}, + {"$ref": "#/$defs/CatalogComponentCommon"}, + { + "type": "object", + "properties": { + "component": {"const": "Canvas"}, + "children": {"$ref": "common_types.json#/$defs/ChildList"}, + }, + "required": ["component", "children"], + }, + ], + }, + "Chart": { + "type": "object", + "allOf": [ + {"$ref": "common_types.json#/$defs/ComponentCommon"}, + {"$ref": "#/$defs/CatalogComponentCommon"}, + { + "type": "object", + "properties": { + "component": {"const": "Chart"}, + "chartType": {"enum": ["doughnut", "pie"]}, + "title": {"$ref": "common_types.json#/$defs/DynamicString"}, + "chartData": {"$ref": "common_types.json#/$defs/DynamicValue"}, + }, + "required": ["component", "chartType", "chartData"], + }, + ], + }, + "GoogleMap": { + "type": "object", + "allOf": [ + {"$ref": "common_types.json#/$defs/ComponentCommon"}, + {"$ref": "#/$defs/CatalogComponentCommon"}, + { + "type": "object", + "properties": { + "component": {"const": "GoogleMap"}, + "center": {"$ref": "common_types.json#/$defs/DynamicValue"}, + "zoom": {"$ref": "common_types.json#/$defs/DynamicNumber"}, + "pins": {"$ref": "common_types.json#/$defs/DynamicValue"}, + }, + "required": ["component", "center", "zoom"], + }, + ], + }, + } + + # Create a new catalog with these components + catalog_schema = copy.deepcopy(catalog_0_9.catalog_schema) + catalog_schema["components"] = custom_components + # Update anyComponent to include them + catalog_schema["$defs"]["anyComponent"]["oneOf"] = [ + {"$ref": "#/components/Canvas"}, + {"$ref": "#/components/Chart"}, + {"$ref": "#/components/GoogleMap"}, + ] + + custom_catalog = A2uiCatalog( + version="0.9", + name="custom", + catalog_schema=catalog_schema, + s2c_schema=catalog_0_9.s2c_schema, + common_types_schema=catalog_0_9.common_types_schema, + ) + + # Valid message + message = [{ + "version": "v0.9", + "updateComponents": { + "surfaceId": "s1", + "components": [ + {"id": "c1", "component": "Canvas", "children": ["child1"]}, + { + "id": "c2", + "component": "Chart", + "chartType": "doughnut", + "chartData": {"path": "/data"}, + }, + ], + }, + }] + custom_catalog.validator.validate(message) + + def test_bundle_0_8(self, catalog_0_8): + bundled = catalog_0_8.validator._bundle_0_8_schemas() + + # Verify styles injection + styles_node = bundled["properties"]["beginRendering"]["properties"]["styles"] + assert styles_node["additionalProperties"] is False + assert "font" in styles_node["properties"] + assert "primaryColor" in styles_node["properties"] + + # Verify component injection + component_node = bundled["properties"]["surfaceUpdate"]["properties"]["components"][ + "items" + ]["properties"]["component"] + assert component_node["additionalProperties"] is False + assert "Text" in component_node["properties"] + assert "Button" in component_node["properties"] diff --git a/a2a_agents/python/a2ui_agent/tests/integration/verify_load_real.py b/a2a_agents/python/a2ui_agent/tests/integration/verify_load_real.py index 9b039bd43..eecc7d6b7 100644 --- a/a2a_agents/python/a2ui_agent/tests/integration/verify_load_real.py +++ b/a2a_agents/python/a2ui_agent/tests/integration/verify_load_real.py @@ -19,27 +19,596 @@ def verify(): - print("Verifying A2uiSchemaManager...") + print('Verifying A2uiSchemaManager...') try: - manager = A2uiSchemaManager("0.8") + manager = A2uiSchemaManager('0.8') catalog = manager.get_effective_catalog() catalog_components = catalog.catalog_schema[CATALOG_COMPONENTS_KEY] - print(f"Successfully loaded 0.8: {len(catalog_components)} components") - print(f"Components found: {list(catalog_components.keys())[:5]}...") + print(f'Successfully loaded 0.8: {len(catalog_components)} components') + print(f'Components found: {list(catalog_components.keys())[:5]}...') + + a2ui_message = [ + {'beginRendering': {'surfaceId': 'contact-card', 'root': 'main_card'}}, + { + 'surfaceUpdate': { + 'surfaceId': 'contact-card', + 'components': [ + { + 'id': 'profile_image', + 'component': { + 'Image': { + 'url': {'path': 'imageUrl'}, + 'usageHint': 'avatar', + 'fit': 'cover', + } + }, + }, + { + 'id': 'user_heading', + 'weight': 1, + 'component': { + 'Text': {'text': {'path': 'name'}, 'usageHint': 'h2'} + }, + }, + { + 'id': 'description_text_1', + 'component': {'Text': {'text': {'path': 'title'}}}, + }, + { + 'id': 'description_text_2', + 'component': {'Text': {'text': {'path': 'team'}}}, + }, + { + 'id': 'description_column', + 'component': { + 'Column': { + 'children': { + 'explicitList': [ + 'user_heading', + 'description_text_1', + 'description_text_2', + ] + }, + 'alignment': 'center', + } + }, + }, + { + 'id': 'calendar_icon', + 'component': { + 'Icon': {'name': {'literalString': 'calendarToday'}} + }, + }, + { + 'id': 'calendar_primary_text', + 'component': { + 'Text': {'usageHint': 'h5', 'text': {'path': 'calendar'}} + }, + }, + { + 'id': 'calendar_secondary_text', + 'component': {'Text': {'text': {'literalString': 'Calendar'}}}, + }, + { + 'id': 'calendar_text_column', + 'component': { + 'Column': { + 'children': { + 'explicitList': [ + 'calendar_primary_text', + 'calendar_secondary_text', + ] + }, + 'distribution': 'start', + 'alignment': 'start', + } + }, + }, + { + 'id': 'info_row_1', + 'component': { + 'Row': { + 'children': { + 'explicitList': [ + 'calendar_icon', + 'calendar_text_column', + ] + }, + 'distribution': 'start', + 'alignment': 'start', + } + }, + }, + { + 'id': 'location_icon', + 'component': { + 'Icon': {'name': {'literalString': 'locationOn'}} + }, + }, + { + 'id': 'location_primary_text', + 'component': { + 'Text': {'usageHint': 'h5', 'text': {'path': 'location'}} + }, + }, + { + 'id': 'location_secondary_text', + 'component': {'Text': {'text': {'literalString': 'Location'}}}, + }, + { + 'id': 'location_text_column', + 'component': { + 'Column': { + 'children': { + 'explicitList': [ + 'location_primary_text', + 'location_secondary_text', + ] + }, + 'distribution': 'start', + 'alignment': 'start', + } + }, + }, + { + 'id': 'info_row_2', + 'component': { + 'Row': { + 'children': { + 'explicitList': [ + 'location_icon', + 'location_text_column', + ] + }, + 'distribution': 'start', + 'alignment': 'start', + } + }, + }, + { + 'id': 'mail_icon', + 'component': {'Icon': {'name': {'literalString': 'mail'}}}, + }, + { + 'id': 'mail_primary_text', + 'component': { + 'Text': {'usageHint': 'h5', 'text': {'path': 'email'}} + }, + }, + { + 'id': 'mail_secondary_text', + 'component': {'Text': {'text': {'literalString': 'Email'}}}, + }, + { + 'id': 'mail_text_column', + 'component': { + 'Column': { + 'children': { + 'explicitList': [ + 'mail_primary_text', + 'mail_secondary_text', + ] + }, + 'distribution': 'start', + 'alignment': 'start', + } + }, + }, + { + 'id': 'info_row_3', + 'component': { + 'Row': { + 'children': { + 'explicitList': ['mail_icon', 'mail_text_column'] + }, + 'distribution': 'start', + 'alignment': 'start', + } + }, + }, + {'id': 'div', 'component': {'Divider': {}}}, + { + 'id': 'call_icon', + 'component': {'Icon': {'name': {'literalString': 'call'}}}, + }, + { + 'id': 'call_primary_text', + 'component': { + 'Text': {'usageHint': 'h5', 'text': {'path': 'mobile'}} + }, + }, + { + 'id': 'call_secondary_text', + 'component': {'Text': {'text': {'literalString': 'Mobile'}}}, + }, + { + 'id': 'call_text_column', + 'component': { + 'Column': { + 'children': { + 'explicitList': [ + 'call_primary_text', + 'call_secondary_text', + ] + }, + 'distribution': 'start', + 'alignment': 'start', + } + }, + }, + { + 'id': 'info_row_4', + 'component': { + 'Row': { + 'children': { + 'explicitList': ['call_icon', 'call_text_column'] + }, + 'distribution': 'start', + 'alignment': 'start', + } + }, + }, + { + 'id': 'info_rows_column', + 'weight': 1, + 'component': { + 'Column': { + 'children': { + 'explicitList': [ + 'info_row_1', + 'info_row_2', + 'info_row_3', + 'info_row_4', + ] + }, + 'alignment': 'stretch', + } + }, + }, + { + 'id': 'button_1_text', + 'component': {'Text': {'text': {'literalString': 'Follow'}}}, + }, + { + 'id': 'button_1', + 'component': { + 'Button': { + 'child': 'button_1_text', + 'primary': True, + 'action': {'name': 'follow_contact'}, + } + }, + }, + { + 'id': 'button_2_text', + 'component': {'Text': {'text': {'literalString': 'Message'}}}, + }, + { + 'id': 'button_2', + 'component': { + 'Button': { + 'child': 'button_2_text', + 'primary': False, + 'action': {'name': 'send_message'}, + } + }, + }, + { + 'id': 'action_buttons_row', + 'component': { + 'Row': { + 'children': {'explicitList': ['button_1', 'button_2']}, + 'distribution': 'center', + 'alignment': 'center', + } + }, + }, + { + 'id': 'link_text', + 'component': { + 'Text': { + 'text': { + 'literalString': '[View Full Profile](/profile)' + } + } + }, + }, + { + 'id': 'link_text_wrapper', + 'component': { + 'Row': { + 'children': {'explicitList': ['link_text']}, + 'distribution': 'center', + 'alignment': 'center', + } + }, + }, + { + 'id': 'main_column', + 'component': { + 'Column': { + 'children': { + 'explicitList': [ + 'profile_image', + 'description_column', + 'div', + 'info_rows_column', + 'action_buttons_row', + 'link_text_wrapper', + ] + }, + 'alignment': 'stretch', + } + }, + }, + { + 'id': 'main_card', + 'component': {'Card': {'child': 'main_column'}}, + }, + ], + } + }, + { + 'dataModelUpdate': { + 'surfaceId': 'contact-card', + 'path': '/', + 'contents': [ + {'key': 'name', 'valueString': 'Casey Smith'}, + {'key': 'title', 'valueString': 'Digital Marketing Specialist'}, + {'key': 'team', 'valueString': 'Growth Team'}, + {'key': 'location', 'valueString': 'New York'}, + {'key': 'email', 'valueString': 'casey.smith@example.com'}, + {'key': 'mobile', 'valueString': '+1 (415) 222-3333'}, + {'key': 'calendar', 'valueString': 'In a meeting'}, + { + 'key': 'imageUrl', + 'valueString': 'http://localhost:10003/static/profile2.png', + }, + ], + } + }, + ] + catalog.validator.validate(a2ui_message) + print('Validation successful') except Exception as e: - print(f"Failed to load 0.8: {e}") + print(f'Failed to load 0.8: {e}') sys.exit(1) try: - manager = A2uiSchemaManager("0.9") + manager = A2uiSchemaManager('0.9') catalog = manager.get_effective_catalog() catalog_components = catalog.catalog_schema[CATALOG_COMPONENTS_KEY] - print(f"Successfully loaded 0.9: {len(catalog_components)} components") - print(f"Components found: {list(catalog_components.keys())}...") + print(f'Successfully loaded 0.9: {len(catalog_components)} components') + print(f'Components found: {list(catalog_components.keys())}...') + + a2ui_message = [ + { + 'version': 'v0.9', + 'createSurface': { + 'surfaceId': 'contact_form_1', + 'catalogId': ( + 'https://a2ui.dev/specification/v0_9/standard_catalog.json' + ), + }, + }, + { + 'version': 'v0.9', + 'updateComponents': { + 'surfaceId': 'contact_form_1', + 'components': [ + {'id': 'root', 'component': 'Card', 'child': 'form_container'}, + { + 'id': 'form_container', + 'component': 'Column', + 'children': [ + 'header_row', + 'name_row', + 'email_group', + 'phone_group', + 'pref_group', + 'divider_1', + 'newsletter_checkbox', + 'submit_button', + ], + 'justify': 'start', + 'align': 'stretch', + }, + { + 'id': 'header_row', + 'component': 'Row', + 'children': ['header_icon', 'header_text'], + 'align': 'center', + }, + {'id': 'header_icon', 'component': 'Icon', 'name': 'mail'}, + { + 'id': 'header_text', + 'component': 'Text', + 'text': '# Contact Us', + 'variant': 'h2', + }, + { + 'id': 'name_row', + 'component': 'Row', + 'children': ['first_name_group', 'last_name_group'], + 'justify': 'spaceBetween', + }, + { + 'id': 'first_name_group', + 'component': 'Column', + 'children': ['first_name_label', 'first_name_field'], + 'weight': 1, + }, + { + 'id': 'first_name_label', + 'component': 'Text', + 'text': 'First Name', + 'variant': 'caption', + }, + { + 'id': 'first_name_field', + 'component': 'TextField', + 'label': 'First Name', + 'value': {'path': '/contact/firstName'}, + 'variant': 'shortText', + }, + { + 'id': 'last_name_group', + 'component': 'Column', + 'children': ['last_name_label', 'last_name_field'], + 'weight': 1, + }, + { + 'id': 'last_name_label', + 'component': 'Text', + 'text': 'Last Name', + 'variant': 'caption', + }, + { + 'id': 'last_name_field', + 'component': 'TextField', + 'label': 'Last Name', + 'value': {'path': '/contact/lastName'}, + 'variant': 'shortText', + }, + { + 'id': 'email_group', + 'component': 'Column', + 'children': ['email_label', 'email_field'], + }, + { + 'id': 'email_label', + 'component': 'Text', + 'text': 'Email Address', + 'variant': 'caption', + }, + { + 'id': 'email_field', + 'component': 'TextField', + 'label': 'Email', + 'value': {'path': '/contact/email'}, + 'variant': 'shortText', + 'checks': [ + { + 'condition': { + 'call': 'required', + 'args': {'value': {'path': '/contact/email'}}, + }, + 'message': 'Email is required.', + }, + { + 'condition': { + 'call': 'email', + 'args': {'value': {'path': '/contact/email'}}, + }, + 'message': 'Please enter a valid email address.', + }, + ], + }, + { + 'id': 'phone_group', + 'component': 'Column', + 'children': ['phone_label', 'phone_field'], + }, + { + 'id': 'phone_label', + 'component': 'Text', + 'text': 'Phone Number', + 'variant': 'caption', + }, + { + 'id': 'phone_field', + 'component': 'TextField', + 'label': 'Phone', + 'value': {'path': '/contact/phone'}, + 'variant': 'shortText', + 'checks': [{ + 'condition': { + 'call': 'regex', + 'args': { + 'value': {'path': '/contact/phone'}, + 'pattern': '^\\d{10}$', + }, + }, + 'message': 'Phone number must be 10 digits.', + }], + }, + { + 'id': 'pref_group', + 'component': 'Column', + 'children': ['pref_label', 'pref_picker'], + }, + { + 'id': 'pref_label', + 'component': 'Text', + 'text': 'Preferred Contact Method', + 'variant': 'caption', + }, + { + 'id': 'pref_picker', + 'component': 'ChoicePicker', + 'variant': 'mutuallyExclusive', + 'options': [ + {'label': 'Email', 'value': 'email'}, + {'label': 'Phone', 'value': 'phone'}, + {'label': 'SMS', 'value': 'sms'}, + ], + 'value': {'path': '/contact/preference'}, + }, + {'id': 'divider_1', 'component': 'Divider', 'axis': 'horizontal'}, + { + 'id': 'newsletter_checkbox', + 'component': 'CheckBox', + 'label': 'Subscribe to our newsletter', + 'value': {'path': '/contact/subscribe'}, + }, + { + 'id': 'submit_button_label', + 'component': 'Text', + 'text': 'Send Message', + }, + { + 'id': 'submit_button', + 'component': 'Button', + 'child': 'submit_button_label', + 'variant': 'primary', + 'action': { + 'event': { + 'name': 'submitContactForm', + 'context': { + 'formId': 'contact_form_1', + 'isNewsletterSubscribed': { + 'path': '/contact/subscribe' + }, + }, + } + }, + }, + ], + }, + }, + { + 'version': 'v0.9', + 'updateDataModel': { + 'surfaceId': 'contact_form_1', + 'path': '/contact', + 'value': { + 'firstName': 'John', + 'lastName': 'Doe', + 'email': 'john.doe@example.com', + 'phone': '1234567890', + 'preference': ['email'], + 'subscribe': True, + }, + }, + }, + {'version': 'v0.9', 'deleteSurface': {'surfaceId': 'contact_form_1'}}, + ] + catalog.validator.validate(a2ui_message) + print('Validation successful') except Exception as e: - print(f"Failed to load 0.9: {e}") + print(f'Failed to load 0.9: {e}') sys.exit(1) -if __name__ == "__main__": +if __name__ == '__main__': verify() From 7f8af239e5463d0617a34a45a342b18346837b93 Mon Sep 17 00:00:00 2001 From: Nan Yu Date: Mon, 26 Jan 2026 23:32:55 +0000 Subject: [PATCH 3/6] Update the agent/contact_lookup sample to use the a2ui-agent python SDK Tested: - [x] The contact_lookup client successfully connected to the contact_lookup agent and rendered the response correctly. --- .../src/a2ui/inference/schema/manager.py | 7 +- samples/agent/adk/contact_lookup/__main__.py | 30 +- .../agent/adk/contact_lookup/a2ui_examples.py | 162 ---- .../agent/adk/contact_lookup/a2ui_schema.py | 788 ------------------ samples/agent/adk/contact_lookup/agent.py | 75 +- .../adk/contact_lookup/agent_executor.py | 6 +- .../examples/action_confirmation.json | 23 + .../contact_lookup/examples/contact_card.json | 54 ++ .../contact_lookup/examples/contact_list.json | 232 ++++++ .../examples/follow_success.json | 60 ++ .../adk/contact_lookup/prompt_builder.py | 109 +-- 11 files changed, 459 insertions(+), 1087 deletions(-) delete mode 100644 samples/agent/adk/contact_lookup/a2ui_examples.py delete mode 100644 samples/agent/adk/contact_lookup/a2ui_schema.py create mode 100644 samples/agent/adk/contact_lookup/examples/action_confirmation.json create mode 100644 samples/agent/adk/contact_lookup/examples/contact_card.json create mode 100644 samples/agent/adk/contact_lookup/examples/contact_list.json create mode 100644 samples/agent/adk/contact_lookup/examples/follow_success.json diff --git a/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/manager.py b/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/manager.py index 10018c05b..ebfa36af9 100644 --- a/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/manager.py +++ b/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/manager.py @@ -36,7 +36,8 @@ find_repo_root, ) from .catalog import CustomCatalogConfig, A2uiCatalog -from ...extension.a2ui_extension import INLINE_CATALOGS_KEY, SUPPORTED_CATALOG_IDS_KEY +from ...extension.a2ui_extension import INLINE_CATALOGS_KEY, SUPPORTED_CATALOG_IDS_KEY, get_a2ui_agent_extension +from a2a.types import AgentExtension def _load_basic_component(version: str, spec_name: str) -> Dict: @@ -322,3 +323,7 @@ def generate_system_prompt( parts.append(f"### Examples:\n{examples_str}") return "\n\n".join(parts) + + def get_agent_extension(self) -> AgentExtension: + catalog_ids = self.catalog_schemas.keys() + return get_a2ui_agent_extension(supported_catalog_ids=list(catalog_ids)) diff --git a/samples/agent/adk/contact_lookup/__main__.py b/samples/agent/adk/contact_lookup/__main__.py index 713d4c312..d371e1622 100644 --- a/samples/agent/adk/contact_lookup/__main__.py +++ b/samples/agent/adk/contact_lookup/__main__.py @@ -19,8 +19,6 @@ from a2a.server.apps import A2AStarletteApplication from a2a.server.request_handlers import DefaultRequestHandler from a2a.server.tasks import InMemoryTaskStore -from a2a.types import AgentCapabilities, AgentCard, AgentSkill -from a2ui.extension.a2ui_extension import get_a2ui_agent_extension from agent import ContactAgent from agent_executor import ContactAgentExecutor from dotenv import load_dotenv @@ -49,39 +47,19 @@ def main(host, port): "GEMINI_API_KEY environment variable not set and GOOGLE_GENAI_USE_VERTEXAI is not TRUE." ) - capabilities = AgentCapabilities( - streaming=True, - extensions=[get_a2ui_agent_extension()], - ) - skill = AgentSkill( - id="find_contact", - name="Find Contact Tool", - description="Helps find contact information for colleagues (e.g., email, location, team).", - tags=["contact", "directory", "people", "finder"], - examples=["Who is David Chen in marketing?", "Find Sarah Lee from engineering"], - ) base_url = f"http://{host}:{port}" + ui_agent = ContactAgent(base_url=base_url, use_ui=True) + text_agent = ContactAgent(base_url=base_url, use_ui=False) - agent_card = AgentCard( - name="Contact Lookup Agent", - description="This agent helps find contact info for people in your organization.", - url=base_url, # <-- Use base_url here - version="1.0.0", - default_input_modes=ContactAgent.SUPPORTED_CONTENT_TYPES, - default_output_modes=ContactAgent.SUPPORTED_CONTENT_TYPES, - capabilities=capabilities, - skills=[skill], - ) - - agent_executor = ContactAgentExecutor(base_url=base_url) + agent_executor = ContactAgentExecutor(ui_agent=ui_agent, text_agent=text_agent) request_handler = DefaultRequestHandler( agent_executor=agent_executor, task_store=InMemoryTaskStore(), ) server = A2AStarletteApplication( - agent_card=agent_card, http_handler=request_handler + agent_card=ui_agent.get_agent_card(), http_handler=request_handler ) import uvicorn diff --git a/samples/agent/adk/contact_lookup/a2ui_examples.py b/samples/agent/adk/contact_lookup/a2ui_examples.py deleted file mode 100644 index dcf74210a..000000000 --- a/samples/agent/adk/contact_lookup/a2ui_examples.py +++ /dev/null @@ -1,162 +0,0 @@ -# Copyright 2025 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# a2ui_examples.py - -CONTACT_UI_EXAMPLES = """ ----BEGIN CONTACT_LIST_EXAMPLE--- -[ - { "beginRendering": { "surfaceId": "contact-list", "root": "root-column", "styles": { "primaryColor": "#007BFF", "font": "Roboto" } } }, - { "surfaceUpdate": { - "surfaceId": "contact-list", - "components": [ - { "id": "root-column", "component": { "Column": { "children": { "explicitList": ["title-heading", "item-list"] } } } }, - { "id": "title-heading", "component": { "Text": { "usageHint": "h1", "text": { "literalString": "Found Contacts" } } } }, - { "id": "item-list", "component": { "List": { "direction": "vertical", "children": { "template": { "componentId": "item-card-template", "dataBinding": "/contacts" } } } } }, - { "id": "item-card-template", "component": { "Card": { "child": "card-layout" } } }, - { "id": "card-layout", "component": { "Row": { "children": { "explicitList": ["template-image", "card-details", "view-button"] }, "alignment": "center" } } }, - { "id": "template-image", "component": { "Image": { "url": { "path": "imageUrl" }, "fit": "cover" } } }, - { "id": "card-details", "component": { "Column": { "children": { "explicitList": ["template-name", "template-title"] } } } }, - { "id": "template-name", "component": { "Text": { "usageHint": "h3", "text": { "path": "name" } } } }, - { "id": "template-title", "component": { "Text": { "text": { "path": "title" } } } }, - { "id": "view-button-text", "component": { "Text": { "text": { "literalString": "View" } } } }, - { "id": "view-button", "component": { "Button": { "child": "view-button-text", "primary": true, "action": { "name": "view_profile", "context": [ { "key": "contactName", "value": { "path": "name" } }, { "key": "department", "value": { "path": "department" } } ] } } } } - ] - } }, - { "dataModelUpdate": { - "surfaceId": "contact-list", - "path": "/", - "contents": [ - {{ "key": "contacts", "valueMap": [ - {{ "key": "contact1", "valueMap": [ - {{ "key": "name", "valueString": "Alice Wonderland" }}, - {{ "key": "phone", "valueString": "+1-555-123-4567" }}, - {{ "key": "email", "valueString": "alice@example.com" }}, - {{ "key": "imageUrl", "valueString": "https://example.com/alice.jpg" }}, - {{ "key": "title", "valueString": "Mad Hatter" }}, - {{ "key": "department", "valueString": "Wonderland" }} - ] }}, - {{ "key": "contact2", "valueMap": [ - {{ "key": "name", "valueString": "Bob The Builder" }}, - {{ "key": "phone", "valueString": "+1-555-765-4321" }}, - {{ "key": "email", "valueString": "bob@example.com" }}, - {{ "key": "imageUrl", "valueString": "https://example.com/bob.jpg" }}, - {{ "key": "title", "valueString": "Construction" }}, - {{ "key": "department", "valueString": "Building" }} - ] }} - ] }} - ] - } } -] ----END CONTACT_LIST_EXAMPLE--- - ----BEGIN CONTACT_CARD_EXAMPLE--- - -[ - { "beginRendering": { "surfaceId":"contact-card","root":"main_card"} }, - { "surfaceUpdate": { "surfaceId":"contact-card", - "components":[ - { "id": "profile_image", "component": { "Image": { "url": { "path": "imageUrl"}, "usageHint": "avatar", "fit": "cover" } } } , - { "id": "user_heading", "weight": 1, "component": { "Text": { "text": { "path": "name"} , "usageHint": "h2"} } } , - { "id": "description_text_1", "component": { "Text": { "text": { "path": "title"} } } } , - { "id": "description_text_2", "component": { "Text": { "text": { "path": "team"} } } } , - { "id": "description_column", "component": { "Column": { "children": { "explicitList": ["user_heading", "description_text_1", "description_text_2"]} , "alignment": "center"} } } , - { "id": "calendar_icon", "component": { "Icon": { "name": { "literalString": "calendar_today"} } } } , - { "id": "calendar_primary_text", "component": { "Text": { "usageHint": "h5", "text": { "path": "calendar"} } } } , - { "id": "calendar_secondary_text", "component": { "Text": { "text": { "literalString": "Calendar"} } } } , - { "id": "calendar_text_column", "component": { "Column": { "children": { "explicitList": ["calendar_primary_text", "calendar_secondary_text"]} , "distribution": "start", "alignment": "start"} } } , - { "id": "info_row_1", "component": { "Row": { "children": { "explicitList": ["calendar_icon", "calendar_text_column"]} , "distribution": "start", "alignment": "start"} } } , - { "id": "location_icon", "component": { "Icon": { "name": { "literalString": "location_on"} } } } , - { "id": "location_primary_text", "component": { "Text": { "usageHint": "h5", "text": { "path": "location"} } } } , - { "id": "location_secondary_text", "component": { "Text": { "text": { "literalString": "Location"} } } } , - { "id": "location_text_column", "component": { "Column": { "children": { "explicitList": ["location_primary_text", "location_secondary_text"]} , "distribution": "start", "alignment": "start"} } } , - { "id": "info_row_2", "component": { "Row": { "children": { "explicitList": ["location_icon", "location_text_column"]} , "distribution": "start", "alignment": "start"} } } , - { "id": "mail_icon", "component": { "Icon": { "name": { "literalString": "mail"} } } } , - { "id": "mail_primary_text", "component": { "Text": { "usageHint": "h5", "text": { "path": "email"} } } } , - { "id": "mail_secondary_text", "component": { "Text": { "text": { "literalString": "Email"} } } } , - { "id": "mail_text_column", "component": { "Column": { "children": { "explicitList": ["mail_primary_text", "mail_secondary_text"]} , "distribution": "start", "alignment": "start"} } } , - { "id": "info_row_3", "component": { "Row": { "children": { "explicitList": ["mail_icon", "mail_text_column"]} , "distribution": "start", "alignment": "start"} } } , - { "id": "div", "component": { "Divider": { } } } , { "id": "call_icon", "component": { "Icon": { "name": { "literalString": "call"} } } } , - { "id": "call_primary_text", "component": { "Text": { "usageHint": "h5", "text": { "path": "mobile"} } } } , - { "id": "call_secondary_text", "component": { "Text": { "text": { "literalString": "Mobile"} } } } , - { "id": "call_text_column", "component": { "Column": { "children": { "explicitList": ["call_primary_text", "call_secondary_text"]} , "distribution": "start", "alignment": "start"} } } , - { "id": "info_row_4", "component": { "Row": { "children": { "explicitList": ["call_icon", "call_text_column"]} , "distribution": "start", "alignment": "start"} } } , - { "id": "info_rows_column", "weight": 1, "component": { "Column": { "children": { "explicitList": ["info_row_1", "info_row_2", "info_row_3", "info_row_4"]} , "alignment": "stretch"} } } , - { "id": "button_1_text", "component": { "Text": { "text": { "literalString": "Follow"} } } } , { "id": "button_1", "component": { "Button": { "child": "button_1_text", "primary": true, "action": { "name": "follow_contact"} } } } , - { "id": "button_2_text", "component": { "Text": { "text": { "literalString": "Message"} } } } , { "id": "button_2", "component": { "Button": { "child": "button_2_text", "primary": false, "action": { "name": "send_message"} } } } , - { "id": "action_buttons_row", "component": { "Row": { "children": { "explicitList": ["button_1", "button_2"]} , "distribution": "center", "alignment": "center"} } } , - { "id": "link_text", "component": { "Text": { "text": { "literalString": "[View Full Profile](/profile)"} } } } , - { "id": "link_text_wrapper", "component": { "Row": { "children": { "explicitList": ["link_text"]} , "distribution": "center", "alignment": "center"} } } , - { "id": "main_column", "component": { "Column": { "children": { "explicitList": ["profile_image", "description_column", "div", "info_rows_column", "action_buttons_row", "link_text_wrapper"]} , "alignment": "stretch"} } } , - { "id": "main_card", "component": { "Card": { "child": "main_column"} } } - ] - } }, - { "dataModelUpdate": { - "surfaceId": "contact-card", - "path": "/", - "contents": [ - { "key": "name", "valueString": "" }, - { "key": "title", "valueString": "" }, - { "key": "team", "valueString": "" }, - { "key": "location", "valueString": "" }, - { "key": "email", "valueString": "" }, - { "key": "mobile", "valueString": "" }, - { "key": "calendar", "valueString": "" }, - { "key": "imageUrl", "valueString": "" } - ] - } } -] ----END CONTACT_CARD_EXAMPLE--- - ----BEGIN ACTION_CONFIRMATION_EXAMPLE--- -[ - { "beginRendering": { "surfaceId": "action-modal", "root": "modal-wrapper", "styles": { "primaryColor": "#007BFF", "font": "Roboto" } } }, - { "surfaceUpdate": { - "surfaceId": "action-modal", - "components": [ - { "id": "modal-wrapper", "component": { "Modal": { "entryPointChild": "hidden-entry-point", "contentChild": "modal-content-column" } } }, - { "id": "hidden-entry-point", "component": { "Text": { "text": { "literalString": "" } } } }, - { "id": "modal-content-column", "component": { "Column": { "children": { "explicitList": ["modal-title", "modal-message", "dismiss-button"] }, "alignment": "center" } } }, - { "id": "modal-title", "component": { "Text": { "usageHint": "h2", "text": { "path": "actionTitle" } } } }, - { "id": "modal-message", "component": { "Text": { "text": { "path": "actionMessage" } } } }, - { "id": "dismiss-button-text", "component": { "Text": { "text": { "literalString": "Dismiss" } } } }, - { "id": "dismiss-button", "component": { "Button": { "child": "dismiss-button-text", "primary": true, "action": { "name": "dismiss_modal" } } } } - ] - } }, - { "dataModelUpdate": { - "surfaceId": "action-modal", - "path": "/", - "contents": [ - { "key": "actionTitle", "valueString": "Action Confirmation" }, - { "key": "actionMessage", "valueString": "Your action has been processed." } - ] - } } -] ----END ACTION_CONFIRMATION_EXAMPLE--- - ----BEGIN FOLLOW_SUCCESS_EXAMPLE--- -[ - { "beginRendering": { "surfaceId": "contact-card", "root": "success_card"} }, - { "surfaceUpdate": { - "surfaceId": "contact-card", - "components": [ - { "id": "success_icon", "component": { "Icon": { "name": { "literalString": "check_circle"}, "size": 48.0, "color": "#4CAF50"} } } , - { "id": "success_text", "component": { "Text": { "text": { "literalString": "Successfully Followed"}, "usageHint": "h2"} } } , - { "id": "success_column", "component": { "Column": { "children": { "explicitList": ["success_icon", "success_text"]} , "alignment": "center"} } } , - { "id": "success_card", "component": { "Card": { "child": "success_column"} } } - ] - } } -] ----END FOLLOW_SUCCESS_EXAMPLE--- -""" diff --git a/samples/agent/adk/contact_lookup/a2ui_schema.py b/samples/agent/adk/contact_lookup/a2ui_schema.py deleted file mode 100644 index 4b6038fdc..000000000 --- a/samples/agent/adk/contact_lookup/a2ui_schema.py +++ /dev/null @@ -1,788 +0,0 @@ -# Copyright 2025 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -# a2ui_schema.py - -A2UI_SCHEMA = r''' -{ - "title": "A2UI Message Schema", - "description": "Describes a JSON payload for an A2UI (Agent to UI) message, which is used to dynamically construct and update user interfaces. A message MUST contain exactly ONE of the action properties: 'beginRendering', 'surfaceUpdate', 'dataModelUpdate', or 'deleteSurface'.", - "type": "object", - "properties": { - "beginRendering": { - "type": "object", - "description": "Signals the client to begin rendering a surface with a root component and specific styles.", - "properties": { - "surfaceId": { - "type": "string", - "description": "The unique identifier for the UI surface to be rendered." - }, - "root": { - "type": "string", - "description": "The ID of the root component to render." - }, - "styles": { - "type": "object", - "description": "Styling information for the UI.", - "properties": { - "font": { - "type": "string", - "description": "The primary font for the UI." - }, - "primaryColor": { - "type": "string", - "description": "The primary UI color as a hexadecimal code (e.g., '#00BFFF').", - "pattern": "^#[0-9a-fA-F]{6}$" - } - } - } - }, - "required": ["root", "surfaceId"] - }, - "surfaceUpdate": { - "type": "object", - "description": "Updates a surface with a new set of components.", - "properties": { - "surfaceId": { - "type": "string", - "description": "The unique identifier for the UI surface to be updated. If you are adding a new surface this *must* be a new, unique identified that has never been used for any existing surfaces shown." - }, - "components": { - "type": "array", - "description": "A list containing all UI components for the surface.", - "minItems": 1, - "items": { - "type": "object", - "description": "Represents a *single* component in a UI widget tree. This component could be one of many supported types.", - "properties": { - "id": { - "type": "string", - "description": "The unique identifier for this component." - }, - "weight": { - "type": "number", - "description": "The relative weight of this component within a Row or Column. This corresponds to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." - }, - "component": { - "type": "object", - "description": "A wrapper object that MUST contain exactly one key, which is the name of the component type (e.g., 'Heading'). The value is an object containing the properties for that specific component.", - "properties": { - "Text": { - "type": "object", - "properties": { - "text": { - "type": "object", - "description": "The text content to display. This can be a literal string or a reference to a value in the data model ('path', e.g., '/doc/title'). While simple Markdown formatting is supported (i.e. without HTML, images, or links), utilizing dedicated UI components is generally preferred for a richer and more structured presentation.", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "usageHint": { - "type": "string", - "description": "A hint for the base text style. One of:\n- `h1`: Largest heading.\n- `h2`: Second largest heading.\n- `h3`: Third largest heading.\n- `h4`: Fourth largest heading.\n- `h5`: Fifth largest heading.\n- `caption`: Small text for captions.\n- `body`: Standard body text.", - "enum": [ - "h1", - "h2", - "h3", - "h4", - "h5", - "caption", - "body" - ] - } - }, - "required": ["text"] - }, - "Image": { - "type": "object", - "properties": { - "url": { - "type": "object", - "description": "The URL of the image to display. This can be a literal string ('literal') or a reference to a value in the data model ('path', e.g. '/thumbnail/url').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "fit": { - "type": "string", - "description": "Specifies how the image should be resized to fit its container. This corresponds to the CSS 'object-fit' property.", - "enum": [ - "contain", - "cover", - "fill", - "none", - "scale-down" - ] - }, - "usageHint": { - "type": "string", - "description": "A hint for the image size and style. One of:\n- `icon`: Small square icon.\n- `avatar`: Circular avatar image.\n- `smallFeature`: Small feature image.\n- `mediumFeature`: Medium feature image.\n- `largeFeature`: Large feature image.\n- `header`: Full-width, full bleed, header image.", - "enum": [ - "icon", - "avatar", - "smallFeature", - "mediumFeature", - "largeFeature", - "header" - ] - } - }, - "required": ["url"] - }, - "Icon": { - "type": "object", - "properties": { - "name": { - "type": "object", - "description": "The name of the icon to display. This can be a literal string or a reference to a value in the data model ('path', e.g. '/form/submit').", - "properties": { - "literalString": { - "type": "string", - "enum": [ - "accountCircle", - "add", - "arrowBack", - "arrowForward", - "attachFile", - "calendarToday", - "call", - "camera", - "check", - "close", - "delete", - "download", - "edit", - "event", - "error", - "favorite", - "favoriteOff", - "folder", - "help", - "home", - "info", - "locationOn", - "lock", - "lockOpen", - "mail", - "menu", - "moreVert", - "moreHoriz", - "notificationsOff", - "notifications", - "payment", - "person", - "phone", - "photo", - "print", - "refresh", - "search", - "send", - "settings", - "share", - "shoppingCart", - "star", - "starHalf", - "starOff", - "upload", - "visibility", - "visibilityOff", - "warning" - ] - }, - "path": { - "type": "string" - } - } - } - }, - "required": ["name"] - }, - "Video": { - "type": "object", - "properties": { - "url": { - "type": "object", - "description": "The URL of the video to display. This can be a literal string or a reference to a value in the data model ('path', e.g. '/video/url').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - } - }, - "required": ["url"] - }, - "AudioPlayer": { - "type": "object", - "properties": { - "url": { - "type": "object", - "description": "The URL of the audio to be played. This can be a literal string ('literal') or a reference to a value in the data model ('path', e.g. '/song/url').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "description": { - "type": "object", - "description": "A description of the audio, such as a title or summary. This can be a literal string or a reference to a value in the data model ('path', e.g. '/song/title').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - } - }, - "required": ["url"] - }, - "Row": { - "type": "object", - "properties": { - "children": { - "type": "object", - "description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.", - "properties": { - "explicitList": { - "type": "array", - "items": { - "type": "string" - } - }, - "template": { - "type": "object", - "description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.", - "properties": { - "componentId": { - "type": "string" - }, - "dataBinding": { - "type": "string" - } - }, - "required": ["componentId", "dataBinding"] - } - } - }, - "distribution": { - "type": "string", - "description": "Defines the arrangement of children along the main axis (horizontally). This corresponds to the CSS 'justify-content' property.", - "enum": [ - "center", - "end", - "spaceAround", - "spaceBetween", - "spaceEvenly", - "start" - ] - }, - "alignment": { - "type": "string", - "description": "Defines the alignment of children along the cross axis (vertically). This corresponds to the CSS 'align-items' property.", - "enum": ["start", "center", "end", "stretch"] - } - }, - "required": ["children"] - }, - "Column": { - "type": "object", - "properties": { - "children": { - "type": "object", - "description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.", - "properties": { - "explicitList": { - "type": "array", - "items": { - "type": "string" - } - }, - "template": { - "type": "object", - "description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.", - "properties": { - "componentId": { - "type": "string" - }, - "dataBinding": { - "type": "string" - } - }, - "required": ["componentId", "dataBinding"] - } - } - }, - "distribution": { - "type": "string", - "description": "Defines the arrangement of children along the main axis (vertically). This corresponds to the CSS 'justify-content' property.", - "enum": [ - "start", - "center", - "end", - "spaceBetween", - "spaceAround", - "spaceEvenly" - ] - }, - "alignment": { - "type": "string", - "description": "Defines the alignment of children along the cross axis (horizontally). This corresponds to the CSS 'align-items' property.", - "enum": ["center", "end", "start", "stretch"] - } - }, - "required": ["children"] - }, - "List": { - "type": "object", - "properties": { - "children": { - "type": "object", - "description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.", - "properties": { - "explicitList": { - "type": "array", - "items": { - "type": "string" - } - }, - "template": { - "type": "object", - "description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.", - "properties": { - "componentId": { - "type": "string" - }, - "dataBinding": { - "type": "string" - } - }, - "required": ["componentId", "dataBinding"] - } - } - }, - "direction": { - "type": "string", - "description": "The direction in which the list items are laid out.", - "enum": ["vertical", "horizontal"] - }, - "alignment": { - "type": "string", - "description": "Defines the alignment of children along the cross axis.", - "enum": ["start", "center", "end", "stretch"] - } - }, - "required": ["children"] - }, - "Card": { - "type": "object", - "properties": { - "child": { - "type": "string", - "description": "The ID of the component to be rendered inside the card." - } - }, - "required": ["child"] - }, - "Tabs": { - "type": "object", - "properties": { - "tabItems": { - "type": "array", - "description": "An array of objects, where each object defines a tab with a title and a child component.", - "items": { - "type": "object", - "properties": { - "title": { - "type": "object", - "description": "The tab title. Defines the value as either a literal value or a path to data model value (e.g. '/options/title').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "child": { - "type": "string" - } - }, - "required": ["title", "child"] - } - } - }, - "required": ["tabItems"] - }, - "Divider": { - "type": "object", - "properties": { - "axis": { - "type": "string", - "description": "The orientation of the divider.", - "enum": ["horizontal", "vertical"] - } - } - }, - "Modal": { - "type": "object", - "properties": { - "entryPointChild": { - "type": "string", - "description": "The ID of the component that opens the modal when interacted with (e.g., a button)." - }, - "contentChild": { - "type": "string", - "description": "The ID of the component to be displayed inside the modal." - } - }, - "required": ["entryPointChild", "contentChild"] - }, - "Button": { - "type": "object", - "properties": { - "child": { - "type": "string", - "description": "The ID of the component to display in the button, typically a Text component." - }, - "primary": { - "type": "boolean", - "description": "Indicates if this button should be styled as the primary action." - }, - "action": { - "type": "object", - "description": "The client-side action to be dispatched when the button is clicked. It includes the action's name and an optional context payload.", - "properties": { - "name": { - "type": "string" - }, - "context": { - "type": "array", - "items": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "value": { - "type": "object", - "description": "Defines the value to be included in the context as either a literal value or a path to a data model value (e.g. '/user/name').", - "properties": { - "path": { - "type": "string" - }, - "literalString": { - "type": "string" - }, - "literalNumber": { - "type": "number" - }, - "literalBoolean": { - "type": "boolean" - } - } - } - }, - "required": ["key", "value"] - } - } - }, - "required": ["name"] - } - }, - "required": ["child", "action"] - }, - "CheckBox": { - "type": "object", - "properties": { - "label": { - "type": "object", - "description": "The text to display next to the checkbox. Defines the value as either a literal value or a path to data model ('path', e.g. '/option/label').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "value": { - "type": "object", - "description": "The current state of the checkbox (true for checked, false for unchecked). This can be a literal boolean ('literalBoolean') or a reference to a value in the data model ('path', e.g. '/filter/open').", - "properties": { - "literalBoolean": { - "type": "boolean" - }, - "path": { - "type": "string" - } - } - } - }, - "required": ["label", "value"] - }, - "TextField": { - "type": "object", - "properties": { - "label": { - "type": "object", - "description": "The text label for the input field. This can be a literal string or a reference to a value in the data model ('path, e.g. '/user/name').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "text": { - "type": "object", - "description": "The value of the text field. This can be a literal string or a reference to a value in the data model ('path', e.g. '/user/name').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "textFieldType": { - "type": "string", - "description": "The type of input field to display.", - "enum": [ - "date", - "longText", - "number", - "shortText", - "obscured" - ] - }, - "validationRegexp": { - "type": "string", - "description": "A regular expression used for client-side validation of the input." - } - }, - "required": ["label"] - }, - "DateTimeInput": { - "type": "object", - "properties": { - "value": { - "type": "object", - "description": "The selected date and/or time value in ISO 8601 format. This can be a literal string ('literalString') or a reference to a value in the data model ('path', e.g. '/user/dob').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "enableDate": { - "type": "boolean", - "description": "If true, allows the user to select a date." - }, - "enableTime": { - "type": "boolean", - "description": "If true, allows the user to select a time." - } - }, - "required": ["value"] - }, - "MultipleChoice": { - "type": "object", - "properties": { - "selections": { - "type": "object", - "description": "The currently selected values for the component. This can be a literal array of strings or a path to an array in the data model('path', e.g. '/hotel/options').", - "properties": { - "literalArray": { - "type": "array", - "items": { - "type": "string" - } - }, - "path": { - "type": "string" - } - } - }, - "options": { - "type": "array", - "description": "An array of available options for the user to choose from.", - "items": { - "type": "object", - "properties": { - "label": { - "type": "object", - "description": "The text to display for this option. This can be a literal string or a reference to a value in the data model (e.g. '/option/label').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "value": { - "type": "string", - "description": "The value to be associated with this option when selected." - } - }, - "required": ["label", "value"] - } - }, - "maxAllowedSelections": { - "type": "integer", - "description": "The maximum number of options that the user is allowed to select." - } - }, - "required": ["selections", "options"] - }, - "Slider": { - "type": "object", - "properties": { - "value": { - "type": "object", - "description": "The current value of the slider. This can be a literal number ('literalNumber') or a reference to a value in the data model ('path', e.g. '/restaurant/cost').", - "properties": { - "literalNumber": { - "type": "number" - }, - "path": { - "type": "string" - } - } - }, - "minValue": { - "type": "number", - "description": "The minimum value of the slider." - }, - "maxValue": { - "type": "number", - "description": "The maximum value of the slider." - } - }, - "required": ["value"] - } - } - } - }, - "required": ["id", "component"] - } - } - }, - "required": ["surfaceId", "components"] - }, - "dataModelUpdate": { - "type": "object", - "description": "Updates the data model for a surface.", - "properties": { - "surfaceId": { - "type": "string", - "description": "The unique identifier for the UI surface this data model update applies to." - }, - "path": { - "type": "string", - "description": "An optional path to a location within the data model (e.g., '/user/name'). If omitted, or set to '/', the entire data model will be replaced." - }, - "contents": { - "type": "array", - "description": "An array of data entries. Each entry must contain a 'key' and exactly one corresponding typed 'value*' property.", - "items": { - "type": "object", - "description": "A single data entry. Exactly one 'value*' property should be provided alongside the key.", - "properties": { - "key": { - "type": "string", - "description": "The key for this data entry." - }, - "valueString": { - "type": "string" - }, - "valueNumber": { - "type": "number" - }, - "valueBoolean": { - "type": "boolean" - }, - "valueMap": { - "description": "Represents a map as an adjacency list.", - "type": "array", - "items": { - "type": "object", - "description": "One entry in the map. Exactly one 'value*' property should be provided alongside the key.", - "properties": { - "key": { - "type": "string" - }, - "valueString": { - "type": "string" - }, - "valueNumber": { - "type": "number" - }, - "valueBoolean": { - "type": "boolean" - } - }, - "required": ["key"] - } - } - }, - "required": ["key"] - } - } - }, - "required": ["contents", "surfaceId"] - }, - "deleteSurface": { - "type": "object", - "description": "Signals the client to delete the surface identified by 'surfaceId'.", - "properties": { - "surfaceId": { - "type": "string", - "description": "The unique identifier for the UI surface to be deleted." - } - }, - "required": ["surfaceId"] - } - } -} -''' diff --git a/samples/agent/adk/contact_lookup/agent.py b/samples/agent/adk/contact_lookup/agent.py index d16b58a9e..702334cef 100644 --- a/samples/agent/adk/contact_lookup/agent.py +++ b/samples/agent/adk/contact_lookup/agent.py @@ -19,23 +19,20 @@ from typing import Any import jsonschema -from a2ui_examples import CONTACT_UI_EXAMPLES # Corrected imports from our new/refactored files -from a2ui_schema import A2UI_SCHEMA from google.adk.agents.llm_agent import LlmAgent from google.adk.artifacts import InMemoryArtifactService from google.adk.memory.in_memory_memory_service import InMemoryMemoryService from google.adk.models.lite_llm import LiteLlm from google.adk.runners import Runner from google.adk.sessions import InMemorySessionService -from google.genai import types -from prompt_builder import ( +from a2a.types import AgentCapabilities, AgentCard, AgentSkill - get_text_prompt, - get_ui_prompt, -) +from google.genai import types +from prompt_builder import get_text_prompt, ROLE_DESCRIPTION, WORKFLOW_DESCRIPTION, UI_DESCRIPTION from tools import get_contact_info +from a2ui.inference.schema.manager import A2uiSchemaManager logger = logging.getLogger(__name__) @@ -48,6 +45,7 @@ class ContactAgent: def __init__(self, base_url: str, use_ui: bool = False): self.base_url = base_url self.use_ui = use_ui + self._schema_manager = A2uiSchemaManager("0.8", basic_examples_path="examples") if use_ui else None self._agent = self._build_agent(use_ui) self._user_id = "remote_agent" self._runner = Runner( @@ -58,22 +56,29 @@ def __init__(self, base_url: str, use_ui: bool = False): memory_service=InMemoryMemoryService(), ) - # --- MODIFICATION: Wrap the schema --- - # Load the A2UI_SCHEMA string into a Python object for validation - try: - # First, load the schema for a *single message* - single_message_schema = json.loads(A2UI_SCHEMA) + def get_agent_card(self) -> AgentCard: + capabilities = AgentCapabilities( + streaming=True, + extensions=[self._schema_manager.get_agent_extension()], + ) + skill = AgentSkill( + id="find_contact", + name="Find Contact Tool", + description="Helps find contact information for colleagues (e.g., email, location, team).", + tags=["contact", "directory", "people", "finder"], + examples=["Who is David Chen in marketing?", "Find Sarah Lee from engineering"], + ) - # The prompt instructs the LLM to return a *list* of messages. - # Therefore, our validation schema must be an *array* of the single message schema. - self.a2ui_schema_object = {"type": "array", "items": single_message_schema} - logger.info( - "A2UI_SCHEMA successfully loaded and wrapped in an array validator." - ) - except json.JSONDecodeError as e: - logger.error(f"CRITICAL: Failed to parse A2UI_SCHEMA: {e}") - self.a2ui_schema_object = None - # --- END MODIFICATION --- + return AgentCard( + name="Contact Lookup Agent", + description="This agent helps find contact info for people in your organization.", + url=self.base_url, + version="1.0.0", + default_input_modes=ContactAgent.SUPPORTED_CONTENT_TYPES, + default_output_modes=ContactAgent.SUPPORTED_CONTENT_TYPES, + capabilities=capabilities, + skills=[skill], + ) def get_processing_message(self) -> str: return "Looking up contact information..." @@ -82,11 +87,18 @@ def _build_agent(self, use_ui: bool) -> LlmAgent: """Builds the LLM agent for the contact agent.""" LITELLM_MODEL = os.getenv("LITELLM_MODEL", "gemini/gemini-2.5-flash") - if use_ui: - instruction = get_ui_prompt(self.base_url, CONTACT_UI_EXAMPLES) - else: - # The text prompt function also returns a complete prompt. - instruction = get_text_prompt() + instruction = ( + self._schema_manager.generate_system_prompt( + role_description=ROLE_DESCRIPTION, + workflow_description=WORKFLOW_DESCRIPTION, + ui_description=UI_DESCRIPTION, + include_schema=True, + include_examples=True, + validate_examples=False, # Use invalid examples to test retry logic + ) + if use_ui + else get_text_prompt() + ) return LlmAgent( model=LiteLlm(model=LITELLM_MODEL), @@ -119,8 +131,9 @@ async def stream(self, query, session_id) -> AsyncIterable[dict[str, Any]]: attempt = 0 current_query_text = query - # Ensure schema was loaded - if self.use_ui and self.a2ui_schema_object is None: + # Ensure catalog schema was loaded + effective_catalog = self._schema_manager.get_effective_catalog() + if self.use_ui and not effective_catalog.catalog_schema: logger.error( "--- ContactAgent.stream: A2UI_SCHEMA is not loaded. " "Cannot perform UI validation. ---" @@ -224,9 +237,7 @@ async def stream(self, query, session_id) -> AsyncIterable[dict[str, Any]]: logger.info( "--- ContactAgent.stream: Validating against A2UI_SCHEMA... ---" ) - jsonschema.validate( - instance=parsed_json_data, schema=self.a2ui_schema_object - ) + effective_catalog.validator.validate(parsed_json_data) # --- End New Validation Steps --- logger.info( diff --git a/samples/agent/adk/contact_lookup/agent_executor.py b/samples/agent/adk/contact_lookup/agent_executor.py index f32110e7e..a3c5ae165 100644 --- a/samples/agent/adk/contact_lookup/agent_executor.py +++ b/samples/agent/adk/contact_lookup/agent_executor.py @@ -41,11 +41,11 @@ class ContactAgentExecutor(AgentExecutor): """Contact AgentExecutor Example.""" - def __init__(self, base_url: str): + def __init__(self, ui_agent: ContactAgent, text_agent: ContactAgent): # Instantiate two agents: one for UI and one for text-only. # The appropriate one will be chosen at execution time. - self.ui_agent = ContactAgent(base_url=base_url, use_ui=True) - self.text_agent = ContactAgent(base_url=base_url, use_ui=False) + self.ui_agent = ui_agent + self.text_agent = text_agent async def execute( self, diff --git a/samples/agent/adk/contact_lookup/examples/action_confirmation.json b/samples/agent/adk/contact_lookup/examples/action_confirmation.json new file mode 100644 index 000000000..f961363d7 --- /dev/null +++ b/samples/agent/adk/contact_lookup/examples/action_confirmation.json @@ -0,0 +1,23 @@ +[ + { "beginRendering": { "surfaceId": "action-modal", "root": "modal-wrapper", "styles": { "primaryColor": "#007BFF", "font": "Roboto" } } }, + { "surfaceUpdate": { + "surfaceId": "action-modal", + "components": [ + { "id": "modal-wrapper", "component": { "Modal": { "entryPointChild": "hidden-entry-point", "contentChild": "modal-content-column" } } }, + { "id": "hidden-entry-point", "component": { "Text": { "text": { "literalString": "" } } } }, + { "id": "modal-content-column", "component": { "Column": { "children": { "explicitList": ["modal-title", "modal-message", "dismiss-button"] }, "alignment": "center" } } }, + { "id": "modal-title", "component": { "Text": { "usageHint": "h2", "text": { "path": "actionTitle" } } } }, + { "id": "modal-message", "component": { "Text": { "text": { "path": "actionMessage" } } } }, + { "id": "dismiss-button-text", "component": { "Text": { "text": { "literalString": "Dismiss" } } } }, + { "id": "dismiss-button", "component": { "Button": { "child": "dismiss-button-text", "primary": true, "action": { "name": "dismiss_modal" } } } } + ] + } }, + { "dataModelUpdate": { + "surfaceId": "action-modal", + "path": "/", + "contents": [ + { "key": "actionTitle", "valueString": "Action Confirmation" }, + { "key": "actionMessage", "valueString": "Your action has been processed." } + ] + } } +] \ No newline at end of file diff --git a/samples/agent/adk/contact_lookup/examples/contact_card.json b/samples/agent/adk/contact_lookup/examples/contact_card.json new file mode 100644 index 000000000..70f0787b3 --- /dev/null +++ b/samples/agent/adk/contact_lookup/examples/contact_card.json @@ -0,0 +1,54 @@ +[ + { "beginRendering": { "surfaceId":"contact-card","root":"main_card"} }, + { "surfaceUpdate": { "surfaceId":"contact-card", + "components":[ + { "id": "profile_image", "component": { "Image": { "url": { "path": "imageUrl"}, "usageHint": "avatar", "fit": "cover" } } } , + { "id": "user_heading", "weight": 1, "component": { "Text": { "text": { "path": "name"} , "usageHint": "h2"} } } , + { "id": "description_text_1", "component": { "Text": { "text": { "path": "title"} } } } , + { "id": "description_text_2", "component": { "Text": { "text": { "path": "team"} } } } , + { "id": "description_column", "component": { "Column": { "children": { "explicitList": ["user_heading", "description_text_1", "description_text_2"]} , "alignment": "center"} } } , + { "id": "calendar_icon", "component": { "Icon": { "name": { "literalString": "calendar_today"} } } } , + { "id": "calendar_primary_text", "component": { "Text": { "usageHint": "h5", "text": { "path": "calendar"} } } } , + { "id": "calendar_secondary_text", "component": { "Text": { "text": { "literalString": "Calendar"} } } } , + { "id": "calendar_text_column", "component": { "Column": { "children": { "explicitList": ["calendar_primary_text", "calendar_secondary_text"]} , "distribution": "start", "alignment": "start"} } } , + { "id": "info_row_1", "component": { "Row": { "children": { "explicitList": ["calendar_icon", "calendar_text_column"]} , "distribution": "start", "alignment": "start"} } } , + { "id": "location_icon", "component": { "Icon": { "name": { "literalString": "location_on"} } } } , + { "id": "location_primary_text", "component": { "Text": { "usageHint": "h5", "text": { "path": "location"} } } } , + { "id": "location_secondary_text", "component": { "Text": { "text": { "literalString": "Location"} } } } , + { "id": "location_text_column", "component": { "Column": { "children": { "explicitList": ["location_primary_text", "location_secondary_text"]} , "distribution": "start", "alignment": "start"} } } , + { "id": "info_row_2", "component": { "Row": { "children": { "explicitList": ["location_icon", "location_text_column"]} , "distribution": "start", "alignment": "start"} } } , + { "id": "mail_icon", "component": { "Icon": { "name": { "literalString": "mail"} } } } , + { "id": "mail_primary_text", "component": { "Text": { "usageHint": "h5", "text": { "path": "email"} } } } , + { "id": "mail_secondary_text", "component": { "Text": { "text": { "literalString": "Email"} } } } , + { "id": "mail_text_column", "component": { "Column": { "children": { "explicitList": ["mail_primary_text", "mail_secondary_text"]} , "distribution": "start", "alignment": "start"} } } , + { "id": "info_row_3", "component": { "Row": { "children": { "explicitList": ["mail_icon", "mail_text_column"]} , "distribution": "start", "alignment": "start"} } } , + { "id": "div", "component": { "Divider": { } } } , { "id": "call_icon", "component": { "Icon": { "name": { "literalString": "call"} } } } , + { "id": "call_primary_text", "component": { "Text": { "usageHint": "h5", "text": { "path": "mobile"} } } } , + { "id": "call_secondary_text", "component": { "Text": { "text": { "literalString": "Mobile"} } } } , + { "id": "call_text_column", "component": { "Column": { "children": { "explicitList": ["call_primary_text", "call_secondary_text"]} , "distribution": "start", "alignment": "start"} } } , + { "id": "info_row_4", "component": { "Row": { "children": { "explicitList": ["call_icon", "call_text_column"]} , "distribution": "start", "alignment": "start"} } } , + { "id": "info_rows_column", "weight": 1, "component": { "Column": { "children": { "explicitList": ["info_row_1", "info_row_2", "info_row_3", "info_row_4"]} , "alignment": "stretch"} } } , + { "id": "button_1_text", "component": { "Text": { "text": { "literalString": "Follow"} } } } , { "id": "button_1", "component": { "Button": { "child": "button_1_text", "primary": true, "action": { "name": "follow_contact"} } } } , + { "id": "button_2_text", "component": { "Text": { "text": { "literalString": "Message"} } } } , { "id": "button_2", "component": { "Button": { "child": "button_2_text", "primary": false, "action": { "name": "send_message"} } } } , + { "id": "action_buttons_row", "component": { "Row": { "children": { "explicitList": ["button_1", "button_2"]} , "distribution": "center", "alignment": "center"} } } , + { "id": "link_text", "component": { "Text": { "text": { "literalString": "[View Full Profile](/profile)"} } } } , + { "id": "link_text_wrapper", "component": { "Row": { "children": { "explicitList": ["link_text"]} , "distribution": "center", "alignment": "center"} } } , + { "id": "main_column", "component": { "Column": { "children": { "explicitList": ["profile_image", "description_column", "div", "info_rows_column", "action_buttons_row", "link_text_wrapper"]} , "alignment": "stretch"} } } , + { "id": "main_card", "component": { "Card": { "child": "main_column"} } } + ] + } }, + { "dataModelUpdate": { + "surfaceId": "contact-card", + "path": "/", + "contents": [ + { "key": "name", "valueString": "" }, + { "key": "title", "valueString": "" }, + { "key": "team", "valueString": "" }, + { "key": "location", "valueString": "" }, + { "key": "email", "valueString": "" }, + { "key": "mobile", "valueString": "" }, + { "key": "calendar", "valueString": "" }, + { "key": "imageUrl", "valueString": "" } + ] + } } +] \ No newline at end of file diff --git a/samples/agent/adk/contact_lookup/examples/contact_list.json b/samples/agent/adk/contact_lookup/examples/contact_list.json new file mode 100644 index 000000000..4f87d069b --- /dev/null +++ b/samples/agent/adk/contact_lookup/examples/contact_list.json @@ -0,0 +1,232 @@ +[ + { + "beginRendering": { + "surfaceId": "contact-list", + "root": "root-column", + "styles": { + "primaryColor": "#007BFF", + "font": "Roboto" + } + } + }, + { + "surfaceUpdate": { + "surfaceId": "contact-list", + "components": [ + { + "id": "root-column", + "component": { + "Column": { + "children": { + "explicitList": [ + "title-heading", + "item-list" + ] + } + } + } + }, + { + "id": "title-heading", + "component": { + "Text": { + "usageHint": "h1", + "text": { + "literalString": "Found Contacts" + } + } + } + }, + { + "id": "item-list", + "component": { + "List": { + "direction": "vertical", + "children": { + "template": { + "componentId": "item-card-template", + "dataBinding": "/contacts" + } + } + } + } + }, + { + "id": "item-card-template", + "component": { + "Card": { + "child": "card-layout" + } + } + }, + { + "id": "card-layout", + "component": { + "Row": { + "children": { + "explicitList": [ + "template-image", + "card-details", + "view-button" + ] + }, + "alignment": "center" + } + } + }, + { + "id": "template-image", + "component": { + "Image": { + "url": { + "path": "imageUrl" + }, + "fit": "cover" + } + } + }, + { + "id": "card-details", + "component": { + "Column": { + "children": { + "explicitList": [ + "template-name", + "template-title" + ] + } + } + } + }, + { + "id": "template-name", + "component": { + "Text": { + "usageHint": "h3", + "text": { + "path": "name" + } + } + } + }, + { + "id": "template-title", + "component": { + "Text": { + "text": { + "path": "title" + } + } + } + }, + { + "id": "view-button-text", + "component": { + "Text": { + "text": { + "literalString": "View" + } + } + } + }, + { + "id": "view-button", + "component": { + "Button": { + "child": "view-button-text", + "primary": true, + "action": { + "name": "view_profile", + "context": [ + { + "key": "contactName", + "value": { + "path": "name" + } + }, + { + "key": "department", + "value": { + "path": "department" + } + } + ] + } + } + } + } + ] + } + }, + { + "dataModelUpdate": { + "surfaceId": "contact-list", + "path": "/", + "contents": [ + { + "key": "contacts", + "valueMap": [ + { + "key": "contact1", + "valueMap": [ + { + "key": "name", + "valueString": "Alice Wonderland" + }, + { + "key": "phone", + "valueString": "+1-555-123-4567" + }, + { + "key": "email", + "valueString": "alice@example.com" + }, + { + "key": "imageUrl", + "valueString": "https://example.com/alice.jpg" + }, + { + "key": "title", + "valueString": "Mad Hatter" + }, + { + "key": "department", + "valueString": "Wonderland" + } + ] + }, + { + "key": "contact2", + "valueMap": [ + { + "key": "name", + "valueString": "Bob The Builder" + }, + { + "key": "phone", + "valueString": "+1-555-765-4321" + }, + { + "key": "email", + "valueString": "bob@example.com" + }, + { + "key": "imageUrl", + "valueString": "https://example.com/bob.jpg" + }, + { + "key": "title", + "valueString": "Construction" + }, + { + "key": "department", + "valueString": "Building" + } + ] + } + ] + } + ] + } + } +] \ No newline at end of file diff --git a/samples/agent/adk/contact_lookup/examples/follow_success.json b/samples/agent/adk/contact_lookup/examples/follow_success.json new file mode 100644 index 000000000..4822dbed4 --- /dev/null +++ b/samples/agent/adk/contact_lookup/examples/follow_success.json @@ -0,0 +1,60 @@ +[ + { + "beginRendering": { + "surfaceId": "contact-card", + "root": "success_card" + } + }, + { + "surfaceUpdate": { + "surfaceId": "contact-card", + "components": [ + { + "id": "success_icon", + "component": { + "Icon": { + "name": { + "literalString": "check_circle" + }, + "size": 48.0, + "color": "#4CAF50" + } + } + }, + { + "id": "success_text", + "component": { + "Text": { + "text": { + "literalString": "Successfully Followed" + }, + "usageHint": "h2" + } + } + }, + { + "id": "success_column", + "component": { + "Column": { + "children": { + "explicitList": [ + "success_icon", + "success_text" + ] + }, + "alignment": "center" + } + } + }, + { + "id": "success_card", + "component": { + "Card": { + "child": "success_column" + } + } + } + ] + } + } +] \ No newline at end of file diff --git a/samples/agent/adk/contact_lookup/prompt_builder.py b/samples/agent/adk/contact_lookup/prompt_builder.py index 3fe269e65..17a67e8de 100644 --- a/samples/agent/adk/contact_lookup/prompt_builder.py +++ b/samples/agent/adk/contact_lookup/prompt_builder.py @@ -12,82 +12,35 @@ # See the License for the specific language governing permissions and # limitations under the License. -from a2ui_examples import CONTACT_UI_EXAMPLES -from a2ui_schema import A2UI_SCHEMA +from a2ui.inference.schema.manager import A2uiSchemaManager -# This is the agent's master instruction, separate from the UI prompt formatting. -AGENT_INSTRUCTION = """ - You are a helpful contact lookup assistant. Your goal is to help users find colleagues using a rich UI. +ROLE_DESCRIPTION="You are a helpful contact lookup assistant. Your final output MUST be a a2ui UI JSON response." - To achieve this, you MUST follow this logic: - - 1. **For finding contacts (e.g., "Who is Alex Jordan?"):** - a. You MUST call the `get_contact_info` tool. Extract the name and department. - b. After receiving the data: - i. If the tool returns a **single contact**, you MUST use the `CONTACT_CARD_EXAMPLE` template. - ii. If the tool returns **multiple contacts**, you MUST use the `CONTACT_LIST_EXAMPLE` template. - iii. If the tool returns an **empty list**, respond with text only and an empty JSON list: "I couldn't find anyone by that name.---a2ui_JSON---[]" - - 2. **For handling a profile view (e.g., "WHO_IS: Alex Jordan..."):** - a. You MUST call the `get_contact_info` tool with the specific name. - b. This will return a single contact. You MUST use the `CONTACT_CARD_EXAMPLE` template. - - 3. **For handling actions (e.g., "USER_WANTS_TO_EMAIL: ..."):** - a. You MUST use the `ACTION_CONFIRMATION_EXAMPLE` template. - b. Populate the `dataModelUpdate.contents` with a confirmation title and message. +WORKFLOW_DESCRIPTION=""" +To generate the response, you MUST follow these rules: +1. Your response MUST be in two parts, separated by the delimiter: `---a2ui_JSON---`. +2. The first part is your conversational text response (e.g., "Here is the contact you requested..."). +3. The second part is a single, raw JSON object which is a list of A2UI messages. +4. The JSON part MUST validate against the A2UI JSON SCHEMA provided below. +5. Buttons that represent the main action on a card or view (e.g., 'Follow', 'Email', 'Search') SHOULD include the `"primary": true` attribute. """ - -def get_ui_prompt(base_url: str, examples: str) -> str: - """ - Constructs the full prompt with UI instructions, rules, examples, and schema. - - Args: - base_url: The base URL for resolving static assets like logos. - examples: A string containing the specific UI examples for the agent's task. - - Returns: - A formatted string to be used as the system prompt for the LLM. - """ - - # --- THIS IS THE FIX --- - # We no longer call .format() on the examples, as it breaks the JSON. - formatted_examples = examples - # --- END FIX --- - - return f""" - You are a helpful contact lookup assistant. Your final output MUST be a a2ui UI JSON response. - - To generate the response, you MUST follow these rules: - 1. Your response MUST be in two parts, separated by the delimiter: `---a2ui_JSON---`. - 2. The first part is your conversational text response (e.g., "Here is the contact you requested..."). - 3. The second part is a single, raw JSON object which is a list of A2UI messages. - 4. The JSON part MUST validate against the A2UI JSON SCHEMA provided below. - 5. Buttons that represent the main action on a card or view (e.g., 'Follow', 'Email', 'Search') SHOULD include the `"primary": true` attribute. - - --- UI TEMPLATE RULES --- - - **For finding contacts (e.g., "Who is Alex Jordan?"):** - a. You MUST call the `get_contact_info` tool. - b. If the tool returns a **single contact**, you MUST use the `CONTACT_CARD_EXAMPLE` template. Populate the `dataModelUpdate.contents` with the contact's details (name, title, email, etc.). - c. If the tool returns **multiple contacts**, you MUST use the `CONTACT_LIST_EXAMPLE` template. Populate the `dataModelUpdate.contents` with the list of contacts for the "contacts" key. - d. If the tool returns an **empty list**, respond with text only and an empty JSON list: "I couldn't find anyone by that name.---a2ui_JSON---[]" - - - **For handling a profile view (e.g., "WHO_IS: Alex Jordan..."):** - a. You MUST call the `get_contact_info` tool with the specific name. - b. This will return a single contact. You MUST use the `CONTACT_CARD_EXAMPLE` template. - - - **For handling actions (e.g., "follow_contact"):** - a. You MUST use the `FOLLOW_SUCCESS_EXAMPLE` template. - b. This will render a new card with a "Successfully Followed" message. - c. Respond with a text confirmation like "You are now following this contact." along with the JSON. - - {formatted_examples} - - ---BEGIN A2UI JSON SCHEMA--- - {A2UI_SCHEMA} - ---END A2UI JSON SCHEMA--- - """ - +UI_DESCRIPTION=""" +- **For finding contacts (e.g., "Who is Alex Jordan?"):** + a. You MUST call the `get_contact_info` tool. + b. If the tool returns a **single contact**, you MUST use the `CONTACT_CARD_EXAMPLE` template. Populate the `dataModelUpdate.contents` with the contact's details (name, title, email, etc.). + c. If the tool returns **multiple contacts**, you MUST use the `CONTACT_LIST_EXAMPLE` template. Populate the `dataModelUpdate.contents` with the list of contacts for the "contacts" key. + d. If the tool returns an **empty list**, respond with text only and an empty JSON list: "I couldn't find anyone by that name.---a2ui_JSON---[]" + +- **For handling a profile view (e.g., "WHO_IS: Alex Jordan..."):** + a. You MUST call the `get_contact_info` tool with the specific name. + b. This will return a single contact. You MUST use the `CONTACT_CARD_EXAMPLE` template. + +- **For handling actions (e.g., "follow_contact"):** + a. You MUST use the `FOLLOW_SUCCESS_EXAMPLE` template. + b. This will render a new card with a "Successfully Followed" message. + c. Respond with a text confirmation like "You are now following this contact." along with the JSON. +""" def get_text_prompt() -> str: """ @@ -109,9 +62,15 @@ def get_text_prompt() -> str: if __name__ == "__main__": - # Example of how to use the prompt builder - my_base_url = "http://localhost:8000" - contact_prompt = get_ui_prompt(my_base_url, CONTACT_UI_EXAMPLES) + # Example of how to use the A2UI Schema Manager to generate a system prompt + contact_prompt = A2uiSchemaManager("0.8", basic_examples_path="examples").generate_system_prompt( + role_description=ROLE_DESCRIPTION, + workflow_description=WORKFLOW_DESCRIPTION, + ui_description=UI_DESCRIPTION, + include_schema=True, + include_examples=True, + validate_examples=False, + ) print(contact_prompt) with open("generated_prompt.txt", "w") as f: f.write(contact_prompt) From 79b62e77b81e7b260d1065a694d2ede3b7123941 Mon Sep 17 00:00:00 2001 From: Nan Yu Date: Wed, 28 Jan 2026 19:17:15 +0000 Subject: [PATCH 4/6] feat: Add schema modifiers to A2uiSchemaManager Introduces a `schema_modifiers` parameter to A2uiSchemaManager, allowing custom callable hooks to transform schemas after loading. This enables flexible schema customization, such as relaxing strict validation constraints during testing. --- .../a2ui/inference/schema/common_modifiers.py | 27 +++++++ .../src/a2ui/inference/schema/manager.py | 33 ++++++--- .../tests/inference/test_modifiers.py | 74 +++++++++++++++++++ .../tests/integration/verify_load_real.py | 13 +++- 4 files changed, 133 insertions(+), 14 deletions(-) create mode 100644 a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/common_modifiers.py create mode 100644 a2a_agents/python/a2ui_agent/tests/inference/test_modifiers.py diff --git a/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/common_modifiers.py b/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/common_modifiers.py new file mode 100644 index 000000000..36a5a3a33 --- /dev/null +++ b/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/common_modifiers.py @@ -0,0 +1,27 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +def remove_strict_validation(schema): + if isinstance(schema, dict): + new_schema = {k: remove_strict_validation(v) for k, v in schema.items()} + if ( + 'additionalProperties' in new_schema + and new_schema['additionalProperties'] is False + ): + del new_schema['additionalProperties'] + return new_schema + elif isinstance(schema, list): + return [remove_strict_validation(item) for item in schema] + return schema diff --git a/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/manager.py b/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/manager.py index ebfa36af9..721d6dd3b 100644 --- a/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/manager.py +++ b/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/manager.py @@ -17,7 +17,7 @@ import logging import os import importlib.resources -from typing import List, Dict, Any, Optional +from typing import List, Dict, Any, Optional, Callable from dataclasses import dataclass, field from .loader import A2uiSchemaLoader, PackageLoader, FileSystemLoader from ..inference_strategy import InferenceStrategy @@ -37,7 +37,6 @@ ) from .catalog import CustomCatalogConfig, A2uiCatalog from ...extension.a2ui_extension import INLINE_CATALOGS_KEY, SUPPORTED_CATALOG_IDS_KEY, get_a2ui_agent_extension -from a2a.types import AgentExtension def _load_basic_component(version: str, spec_name: str) -> Dict: @@ -122,6 +121,7 @@ def __init__( custom_catalogs: Optional[List[CustomCatalogConfig]] = None, exclude_basic_catalog: bool = False, accepts_inline_catalogs: bool = False, + schema_modifiers: List[Callable[[Dict[str, Any]], Dict[str, Any]]] = None, ): self._version = version self._exclude_basic_catalog = exclude_basic_catalog @@ -132,6 +132,7 @@ def __init__( self._supported_catalogs: Dict[str, A2uiCatalog] = {} self._catalog_example_paths: Dict[str, str] = {} self._basic_catalog = None + self._schema_modifiers = schema_modifiers self._load_schemas(version, custom_catalogs, basic_examples_path) @property @@ -142,6 +143,12 @@ def accepts_inline_catalogs(self) -> bool: def supported_catalogs(self) -> Dict[str, A2uiCatalog]: return self._supported_catalogs + def _apply_modifiers(self, schema: Dict[str, Any]) -> Dict[str, Any]: + if self._schema_modifiers: + for modifier in self._schema_modifiers: + schema = modifier(schema) + return schema + def _load_schemas( self, version: str, @@ -156,13 +163,17 @@ def _load_schemas( ) # Load server-to-client and common types schemas - self._server_to_client_schema = _load_basic_component( - version, SERVER_TO_CLIENT_SCHEMA_KEY + self._server_to_client_schema = self._apply_modifiers( + _load_basic_component(version, SERVER_TO_CLIENT_SCHEMA_KEY) + ) + self._common_types_schema = self._apply_modifiers( + _load_basic_component(version, COMMON_TYPES_SCHEMA_KEY) ) - self._common_types_schema = _load_basic_component(version, COMMON_TYPES_SCHEMA_KEY) # Process basic catalog - basic_catalog_schema = _load_basic_component(version, CATALOG_SCHEMA_KEY) + basic_catalog_schema = self._apply_modifiers( + _load_basic_component(version, CATALOG_SCHEMA_KEY) + ) if not basic_catalog_schema: basic_catalog_schema = {} @@ -192,14 +203,16 @@ def _load_schemas( # Process custom catalogs if custom_catalogs: for config in custom_catalogs: - custom_catalog_schema = _load_from_path(config.catalog_path) + custom_catalog_schema = self._apply_modifiers( + _load_from_path(config.catalog_path) + ) resolved_catalog_schema = A2uiCatalog.resolve_schema( basic_catalog_schema, custom_catalog_schema ) catalog = A2uiCatalog( version=version, name=config.name, - catalog_schema=resolved_catalog_schema, + catalog_schema=self._apply_modifiers(resolved_catalog_schema), s2c_schema=self._server_to_client_schema, common_types_schema=self._common_types_schema, ) @@ -323,7 +336,3 @@ def generate_system_prompt( parts.append(f"### Examples:\n{examples_str}") return "\n\n".join(parts) - - def get_agent_extension(self) -> AgentExtension: - catalog_ids = self.catalog_schemas.keys() - return get_a2ui_agent_extension(supported_catalog_ids=list(catalog_ids)) diff --git a/a2a_agents/python/a2ui_agent/tests/inference/test_modifiers.py b/a2a_agents/python/a2ui_agent/tests/inference/test_modifiers.py new file mode 100644 index 000000000..4b0939b8c --- /dev/null +++ b/a2a_agents/python/a2ui_agent/tests/inference/test_modifiers.py @@ -0,0 +1,74 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest +from unittest.mock import patch +from a2ui.inference.schema.manager import A2uiSchemaManager +from a2ui.inference.schema.common_modifiers import remove_strict_validation + + +def test_remove_strict_validation(): + """Tests the remove_strict_validation modifier.""" + schema = { + "type": "object", + "properties": { + "a": {"type": "string", "additionalProperties": False}, + "b": { + "type": "array", + "items": {"type": "object", "additionalProperties": False}, + }, + }, + "additionalProperties": False, + } + + modified = remove_strict_validation(schema) + + # Check that additionalProperties: False is removed + assert "additionalProperties" not in modified + assert "additionalProperties" not in modified["properties"]["a"] + assert "additionalProperties" not in modified["properties"]["b"]["items"] + + # Check that it didn't mutate the original + assert schema["additionalProperties"] is False + assert schema["properties"]["a"]["additionalProperties"] is False + + +def test_manager_with_modifiers(): + """Tests that A2uiSchemaManager applies modifiers during loading.""" + # Mock _load_basic_component to return a simple schema with strict validation + mock_schema = {"type": "object", "additionalProperties": False} + with patch( + "a2ui.inference.schema.manager._load_basic_component", return_value=mock_schema + ): + manager = A2uiSchemaManager("0.8", schema_modifiers=[remove_strict_validation]) + + # Verify that loaded schemas have modifiers applied + assert "additionalProperties" not in manager._server_to_client_schema + assert "additionalProperties" not in manager._common_types_schema + + # basic catalog should also be modified + for catalog in manager._supported_catalogs.values(): + assert "additionalProperties" not in catalog.catalog_schema + + +def test_manager_no_modifiers(): + """Tests that A2uiSchemaManager works fine without modifiers.""" + mock_schema = {"type": "object", "additionalProperties": False} + with patch( + "a2ui.inference.schema.manager._load_basic_component", return_value=mock_schema + ): + manager = A2uiSchemaManager("0.8", schema_modifiers=None) + + # Verify that schemas are NOT modified + assert manager._server_to_client_schema["additionalProperties"] is False diff --git a/a2a_agents/python/a2ui_agent/tests/integration/verify_load_real.py b/a2a_agents/python/a2ui_agent/tests/integration/verify_load_real.py index eecc7d6b7..bb0a67ed3 100644 --- a/a2a_agents/python/a2ui_agent/tests/integration/verify_load_real.py +++ b/a2a_agents/python/a2ui_agent/tests/integration/verify_load_real.py @@ -16,12 +16,13 @@ from a2ui.inference.schema.manager import A2uiSchemaManager from a2ui.inference.schema.constants import CATALOG_COMPONENTS_KEY +from a2ui.inference.schema.common_modifiers import remove_strict_validation def verify(): print('Verifying A2uiSchemaManager...') try: - manager = A2uiSchemaManager('0.8') + manager = A2uiSchemaManager('0.8', schema_modifiers=[remove_strict_validation]) catalog = manager.get_effective_catalog() catalog_components = catalog.catalog_schema[CATALOG_COMPONENTS_KEY] print(f'Successfully loaded 0.8: {len(catalog_components)} components') @@ -364,6 +365,13 @@ def verify(): 'key': 'imageUrl', 'valueString': 'http://localhost:10003/static/profile2.png', }, + { + 'key': 'contacts', + 'valueMap': [{ + 'key': 'contact1', + 'valueMap': [{'key': 'name', 'valueString': 'Casey Smith'}], + }], + }, ], } }, @@ -375,7 +383,7 @@ def verify(): sys.exit(1) try: - manager = A2uiSchemaManager('0.9') + manager = A2uiSchemaManager('0.9', schema_modifiers=[remove_strict_validation]) catalog = manager.get_effective_catalog() catalog_components = catalog.catalog_schema[CATALOG_COMPONENTS_KEY] print(f'Successfully loaded 0.9: {len(catalog_components)} components') @@ -389,6 +397,7 @@ def verify(): 'catalogId': ( 'https://a2ui.dev/specification/v0_9/standard_catalog.json' ), + 'fakeProperty': 'should be allowed', }, }, { From 09e6b069ff2642b5e25297039153ff687979f67b Mon Sep 17 00:00:00 2001 From: Nan Yu Date: Wed, 28 Jan 2026 00:22:15 +0000 Subject: [PATCH 5/6] Update contact_multiple_surfaces sample It updates the sample to use the A2uiSchemaManager from the a2ui-agent python SDK. Tested: - [x] The `contact` lit client successfully connected to the `contact_multiple_surfaces` agent and rendered the response correctly. --- .../src/a2ui/inference/schema/manager.py | 1 + .../adk/contact_multiple_surfaces/__main__.py | 29 +- .../a2ui_examples.py | 66 -- .../contact_multiple_surfaces/a2ui_schema.py | 792 ------------------ .../adk/contact_multiple_surfaces/agent.py | 95 ++- .../agent_executor.py | 15 +- .../prompt_builder.py | 132 ++- samples/client/lit/contact/client.ts | 4 +- 8 files changed, 126 insertions(+), 1008 deletions(-) delete mode 100644 samples/agent/adk/contact_multiple_surfaces/a2ui_schema.py diff --git a/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/manager.py b/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/manager.py index 721d6dd3b..7ee3f43e9 100644 --- a/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/manager.py +++ b/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/manager.py @@ -37,6 +37,7 @@ ) from .catalog import CustomCatalogConfig, A2uiCatalog from ...extension.a2ui_extension import INLINE_CATALOGS_KEY, SUPPORTED_CATALOG_IDS_KEY, get_a2ui_agent_extension +from a2a.types import AgentExtension def _load_basic_component(version: str, spec_name: str) -> Dict: diff --git a/samples/agent/adk/contact_multiple_surfaces/__main__.py b/samples/agent/adk/contact_multiple_surfaces/__main__.py index 76289ba96..026fa536f 100644 --- a/samples/agent/adk/contact_multiple_surfaces/__main__.py +++ b/samples/agent/adk/contact_multiple_surfaces/__main__.py @@ -19,8 +19,6 @@ from a2a.server.apps import A2AStarletteApplication from a2a.server.request_handlers import DefaultRequestHandler from a2a.server.tasks import InMemoryTaskStore -from a2a.types import AgentCapabilities, AgentCard, AgentSkill -from a2ui.extension.a2ui_extension import get_a2ui_agent_extension from agent import ContactAgent from agent_executor import ContactAgentExecutor from dotenv import load_dotenv @@ -49,39 +47,18 @@ def main(host, port): "GEMINI_API_KEY environment variable not set and GOOGLE_GENAI_USE_VERTEXAI is not TRUE." ) - capabilities = AgentCapabilities( - streaming=True, - extensions=[get_a2ui_agent_extension()], - ) - skill = AgentSkill( - id="find_contact", - name="Find Contact Tool", - description="Helps find contact information for colleagues (e.g., email, location, team).", - tags=["contact", "directory", "people", "finder"], - examples=["Who is David Chen in marketing?", "Find Sarah Lee from engineering"], - ) - base_url = f"http://{host}:{port}" - agent_card = AgentCard( - name="Contact Lookup Agent", - description="This agent helps find contact info for people in your organization.", - url=base_url, # <-- Use base_url here - version="1.0.0", - default_input_modes=ContactAgent.SUPPORTED_CONTENT_TYPES, - default_output_modes=ContactAgent.SUPPORTED_CONTENT_TYPES, - capabilities=capabilities, - skills=[skill], - ) + agent = ContactAgent(base_url=base_url, use_ui=True) - agent_executor = ContactAgentExecutor(base_url=base_url) + agent_executor = ContactAgentExecutor(agent=agent) request_handler = DefaultRequestHandler( agent_executor=agent_executor, task_store=InMemoryTaskStore(), ) server = A2AStarletteApplication( - agent_card=agent_card, http_handler=request_handler + agent_card=agent.get_agent_card(), http_handler=request_handler ) import uvicorn diff --git a/samples/agent/adk/contact_multiple_surfaces/a2ui_examples.py b/samples/agent/adk/contact_multiple_surfaces/a2ui_examples.py index da2884d9c..0f19ac38e 100644 --- a/samples/agent/adk/contact_multiple_surfaces/a2ui_examples.py +++ b/samples/agent/adk/contact_multiple_surfaces/a2ui_examples.py @@ -18,7 +18,6 @@ from pathlib import Path import jsonschema -from a2ui_schema import A2UI_SCHEMA logger = logging.getLogger(__name__) @@ -34,71 +33,6 @@ FLOOR_PLAN_FILE = "floor_plan.json" -def load_examples(base_url: str = "http://localhost:10004") -> str: - """ - Loads, validates, and formats the UI examples from JSON files. - - Args: - base_url: The base URL to replace placeholder URLs with. - (Currently examples have http://localhost:10004 hardcoded, - but we can make this dynamic if needed). - - Returns: - A string containing all formatted examples for the prompt. - """ - - # Pre-parse validator - try: - single_msg_schema = json.loads(A2UI_SCHEMA) - # Examples are typically lists of messages - list_schema = {"type": "array", "items": single_msg_schema} - except json.JSONDecodeError: - logger.error("Failed to parse A2UI_SCHEMA for validation") - list_schema = None - - examples_dir = Path(os.path.dirname(__file__)) / "examples" - formatted_output = [] - - for curr_name, filename in EXAMPLE_FILES.items(): - file_path = examples_dir / filename - try: - content = file_path.read_text(encoding="utf-8") - - # basic replacement if we decide to template the URL in JSON files - # content = content.replace("{{BASE_URL}}", base_url) - - # Validation - if list_schema: - try: - data = json.loads(content) - jsonschema.validate(instance=data, schema=list_schema) - except (json.JSONDecodeError, jsonschema.ValidationError) as e: - logger.warning(f"Example {filename} validation failed: {e}") - - formatted_output.append(f"---BEGIN {curr_name}---") - # Handle examples that include user/model text - if curr_name == "ORG_CHART_EXAMPLE": - formatted_output.append("User: Show me the org chart for Casey Smith") - formatted_output.append("Model: Here is the organizational chart.") - formatted_output.append("---a2ui_JSON---") - elif curr_name == "MULTI_SURFACE_EXAMPLE": - formatted_output.append("User: Full profile for Casey Smith") - formatted_output.append("Model: Here is the full profile including contact details and org chart.") - formatted_output.append("---a2ui_JSON---") - elif curr_name == "CHART_NODE_CLICK_EXAMPLE": - formatted_output.append('User: ACTION: chart_node_click (context: clickedNodeName="John Smith") (from modal)') - formatted_output.append("Model: Here is the profile for John Smith.") - formatted_output.append("---a2ui_JSON---") - - formatted_output.append(content.strip()) - formatted_output.append(f"---END {curr_name}---") - formatted_output.append("") # Newline - - except FileNotFoundError: - logger.error(f"Example file not found: {file_path}") - - return "\n".join(formatted_output) - def load_floor_plan_example() -> str: """Loads the floor plan example specifically.""" examples_dir = Path(os.path.dirname(__file__)) / "examples" diff --git a/samples/agent/adk/contact_multiple_surfaces/a2ui_schema.py b/samples/agent/adk/contact_multiple_surfaces/a2ui_schema.py deleted file mode 100644 index f4c776d80..000000000 --- a/samples/agent/adk/contact_multiple_surfaces/a2ui_schema.py +++ /dev/null @@ -1,792 +0,0 @@ -# Copyright 2025 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -# a2ui_schema.py - -A2UI_SCHEMA = r''' -{ - "title": "A2UI Message Schema", - "description": "Describes a JSON payload for an A2UI (Agent to UI) message, which is used to dynamically construct and update user interfaces. A message MUST contain exactly ONE of the action properties: 'beginRendering', 'surfaceUpdate', 'dataModelUpdate', or 'deleteSurface'.", - "type": "object", - "properties": { - "beginRendering": { - "type": "object", - "description": "Signals the client to begin rendering a surface with a root component and specific styles.", - "properties": { - "surfaceId": { - "type": "string", - "description": "The unique identifier for the UI surface to be rendered." - }, - "root": { - "type": "string", - "description": "The ID of the root component to render." - }, - "styles": { - "type": "object", - "description": "Styling information for the UI.", - "properties": { - "font": { - "type": "string", - "description": "The primary font for the UI." - }, - "primaryColor": { - "type": "string", - "description": "The primary UI color as a hexadecimal code (e.g., '#00BFFF').", - "pattern": "^#[0-9a-fA-F]{6}$" - } - } - } - }, - "required": ["root", "surfaceId"] - }, - "surfaceUpdate": { - "type": "object", - "description": "Updates a surface with a new set of components.", - "properties": { - "surfaceId": { - "type": "string", - "description": "The unique identifier for the UI surface to be updated. If you are adding a new surface this *must* be a new, unique identified that has never been used for any existing surfaces shown." - }, - "components": { - "type": "array", - "description": "A list containing all UI components for the surface.", - "minItems": 1, - "items": { - "type": "object", - "description": "Represents a *single* component in a UI widget tree. This component could be one of many supported types.", - "properties": { - "id": { - "type": "string", - "description": "The unique identifier for this component." - }, - "weight": { - "type": "number", - "description": "The relative weight of this component within a Row or Column. This corresponds to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." - }, - "component": { - "type": "object", - "description": "A wrapper object that MUST contain exactly one key, which is the name of the component type (e.g., 'Heading'). The value is an object containing the properties for that specific component.", - "properties": { - "Text": { - "type": "object", - "properties": { - "text": { - "type": "object", - "description": "The text content to display. This can be a literal string or a reference to a value in the data model ('path', e.g., '/doc/title'). While simple Markdown formatting is supported (i.e. without HTML, images, or links), utilizing dedicated UI components is generally preferred for a richer and more structured presentation.", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "usageHint": { - "type": "string", - "description": "A hint for the base text style. One of:\n- `h1`: Largest heading.\n- `h2`: Second largest heading.\n- `h3`: Third largest heading.\n- `h4`: Fourth largest heading.\n- `h5`: Fifth largest heading.\n- `caption`: Small text for captions.\n- `body`: Standard body text.", - "enum": [ - "h1", - "h2", - "h3", - "h4", - "h5", - "caption", - "body" - ] - } - }, - "required": ["text"] - }, - "Image": { - "type": "object", - "properties": { - "url": { - "type": "object", - "description": "The URL of the image to display. This can be a literal string ('literal') or a reference to a value in the data model ('path', e.g. '/thumbnail/url').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "fit": { - "type": "string", - "description": "Specifies how the image should be resized to fit its container. This corresponds to the CSS 'object-fit' property.", - "enum": [ - "contain", - "cover", - "fill", - "none", - "scale-down" - ] - }, - "usageHint": { - "type": "string", - "description": "A hint for the image size and style. One of:\n- `icon`: Small square icon.\n- `avatar`: Circular avatar image.\n- `smallFeature`: Small feature image.\n- `mediumFeature`: Medium feature image.\n- `largeFeature`: Large feature image.\n- `header`: Full-width, full bleed, header image.", - "enum": [ - "icon", - "avatar", - "smallFeature", - "mediumFeature", - "largeFeature", - "header" - ] - } - }, - "required": ["url"] - }, - "Icon": { - "type": "object", - "properties": { - "name": { - "type": "object", - "description": "The name of the icon to display. This can be a literal string or a reference to a value in the data model ('path', e.g. '/form/submit').", - "properties": { - "literalString": { - "type": "string", - "enum": [ - "accountCircle", - "add", - "arrowBack", - "arrowForward", - "attachFile", - "calendarToday", - "call", - "camera", - "check", - "close", - "delete", - "download", - "edit", - "event", - "error", - "favorite", - "favoriteOff", - "folder", - "help", - "home", - "info", - "locationOn", - "lock", - "lockOpen", - "mail", - "menu", - "moreVert", - "moreHoriz", - "notificationsOff", - "notifications", - "payment", - "person", - "phone", - "photo", - "print", - "refresh", - "search", - "send", - "settings", - "share", - "shoppingCart", - "star", - "starHalf", - "starOff", - "upload", - "visibility", - "visibilityOff", - "warning" - ] - }, - "path": { - "type": "string" - } - } - } - }, - "required": ["name"] - }, - "Video": { - "type": "object", - "properties": { - "url": { - "type": "object", - "description": "The URL of the video to display. This can be a literal string or a reference to a value in the data model ('path', e.g. '/video/url').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - } - }, - "required": ["url"] - }, - "AudioPlayer": { - "type": "object", - "properties": { - "url": { - "type": "object", - "description": "The URL of the audio to be played. This can be a literal string ('literal') or a reference to a value in the data model ('path', e.g. '/song/url').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "description": { - "type": "object", - "description": "A description of the audio, such as a title or summary. This can be a literal string or a reference to a value in the data model ('path', e.g. '/song/title').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - } - }, - "required": ["url"] - }, - "Row": { - "type": "object", - "properties": { - "children": { - "type": "object", - "description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.", - "properties": { - "explicitList": { - "type": "array", - "items": { - "type": "string" - } - }, - "template": { - "type": "object", - "description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.", - "properties": { - "componentId": { - "type": "string" - }, - "dataBinding": { - "type": "string" - } - }, - "required": ["componentId", "dataBinding"] - } - } - }, - "distribution": { - "type": "string", - "description": "Defines the arrangement of children along the main axis (horizontally). This corresponds to the CSS 'justify-content' property.", - "enum": [ - "center", - "end", - "spaceAround", - "spaceBetween", - "spaceEvenly", - "start" - ] - }, - "alignment": { - "type": "string", - "description": "Defines the alignment of children along the cross axis (vertically). This corresponds to the CSS 'align-items' property.", - "enum": ["start", "center", "end", "stretch"] - } - }, - "required": ["children"] - }, - "Column": { - "type": "object", - "properties": { - "children": { - "type": "object", - "description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.", - "properties": { - "explicitList": { - "type": "array", - "items": { - "type": "string" - } - }, - "template": { - "type": "object", - "description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.", - "properties": { - "componentId": { - "type": "string" - }, - "dataBinding": { - "type": "string" - } - }, - "required": ["componentId", "dataBinding"] - } - } - }, - "distribution": { - "type": "string", - "description": "Defines the arrangement of children along the main axis (vertically). This corresponds to the CSS 'justify-content' property.", - "enum": [ - "start", - "center", - "end", - "spaceBetween", - "spaceAround", - "spaceEvenly" - ] - }, - "alignment": { - "type": "string", - "description": "Defines the alignment of children along the cross axis (horizontally). This corresponds to the CSS 'align-items' property.", - "enum": ["center", "end", "start", "stretch"] - } - }, - "required": ["children"] - }, - "List": { - "type": "object", - "properties": { - "children": { - "type": "object", - "description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.", - "properties": { - "explicitList": { - "type": "array", - "items": { - "type": "string" - } - }, - "template": { - "type": "object", - "description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.", - "properties": { - "componentId": { - "type": "string" - }, - "dataBinding": { - "type": "string" - } - }, - "required": ["componentId", "dataBinding"] - } - } - }, - "direction": { - "type": "string", - "description": "The direction in which the list items are laid out.", - "enum": ["vertical", "horizontal"] - }, - "alignment": { - "type": "string", - "description": "Defines the alignment of children along the cross axis.", - "enum": ["start", "center", "end", "stretch"] - } - }, - "required": ["children"] - }, - "Card": { - "type": "object", - "properties": { - "child": { - "type": "string", - "description": "The ID of the component to be rendered inside the card." - } - }, - "required": ["child"] - }, - "Tabs": { - "type": "object", - "properties": { - "tabItems": { - "type": "array", - "description": "An array of objects, where each object defines a tab with a title and a child component.", - "items": { - "type": "object", - "properties": { - "title": { - "type": "object", - "description": "The tab title. Defines the value as either a literal value or a path to data model value (e.g. '/options/title').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "child": { - "type": "string" - } - }, - "required": ["title", "child"] - } - } - }, - "required": ["tabItems"] - }, - "Divider": { - "type": "object", - "properties": { - "axis": { - "type": "string", - "description": "The orientation of the divider.", - "enum": ["horizontal", "vertical"] - } - } - }, - "Modal": { - "type": "object", - "properties": { - "entryPointChild": { - "type": "string", - "description": "The ID of the component that opens the modal when interacted with (e.g., a button)." - }, - "contentChild": { - "type": "string", - "description": "The ID of the component to be displayed inside the modal." - } - }, - "required": ["entryPointChild", "contentChild"] - }, - "Button": { - "type": "object", - "properties": { - "child": { - "type": "string", - "description": "The ID of the component to display in the button, typically a Text component." - }, - "primary": { - "type": "boolean", - "description": "Indicates if this button should be styled as the primary action." - }, - "action": { - "type": "object", - "description": "The client-side action to be dispatched when the button is clicked. It includes the action's name and an optional context payload.", - "properties": { - "name": { - "type": "string" - }, - "context": { - "type": "array", - "items": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "value": { - "type": "object", - "description": "Defines the value to be included in the context as either a literal value or a path to a data model value (e.g. '/user/name').", - "properties": { - "path": { - "type": "string" - }, - "literalString": { - "type": "string" - }, - "literalNumber": { - "type": "number" - }, - "literalBoolean": { - "type": "boolean" - } - } - } - }, - "required": ["key", "value"] - } - } - }, - "required": ["name"] - } - }, - "required": ["child", "action"] - }, - "CheckBox": { - "type": "object", - "properties": { - "label": { - "type": "object", - "description": "The text to display next to the checkbox. Defines the value as either a literal value or a path to data model ('path', e.g. '/option/label').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "value": { - "type": "object", - "description": "The current state of the checkbox (true for checked, false for unchecked). This can be a literal boolean ('literalBoolean') or a reference to a value in the data model ('path', e.g. '/filter/open').", - "properties": { - "literalBoolean": { - "type": "boolean" - }, - "path": { - "type": "string" - } - } - } - }, - "required": ["label", "value"] - }, - "TextField": { - "type": "object", - "properties": { - "label": { - "type": "object", - "description": "The text label for the input field. This can be a literal string or a reference to a value in the data model ('path, e.g. '/user/name').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "text": { - "type": "object", - "description": "The value of the text field. This can be a literal string or a reference to a value in the data model ('path', e.g. '/user/name').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "textFieldType": { - "type": "string", - "description": "The type of input field to display.", - "enum": [ - "date", - "longText", - "number", - "shortText", - "obscured" - ] - }, - "validationRegexp": { - "type": "string", - "description": "A regular expression used for client-side validation of the input." - } - }, - "required": ["label"] - }, - "DateTimeInput": { - "type": "object", - "properties": { - "value": { - "type": "object", - "description": "The selected date and/or time value. This can be a literal string ('literalString') or a reference to a value in the data model ('path', e.g. '/user/dob').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "enableDate": { - "type": "boolean", - "description": "If true, allows the user to select a date." - }, - "enableTime": { - "type": "boolean", - "description": "If true, allows the user to select a time." - }, - "outputFormat": { - "type": "string", - "description": "The desired format for the output string after a date or time is selected." - } - }, - "required": ["value"] - }, - "MultipleChoice": { - "type": "object", - "properties": { - "selections": { - "type": "object", - "description": "The currently selected values for the component. This can be a literal array of strings or a path to an array in the data model('path', e.g. '/hotel/options').", - "properties": { - "literalArray": { - "type": "array", - "items": { - "type": "string" - } - }, - "path": { - "type": "string" - } - } - }, - "options": { - "type": "array", - "description": "An array of available options for the user to choose from.", - "items": { - "type": "object", - "properties": { - "label": { - "type": "object", - "description": "The text to display for this option. This can be a literal string or a reference to a value in the data model (e.g. '/option/label').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "value": { - "type": "string", - "description": "The value to be associated with this option when selected." - } - }, - "required": ["label", "value"] - } - }, - "maxAllowedSelections": { - "type": "integer", - "description": "The maximum number of options that the user is allowed to select." - } - }, - "required": ["selections", "options"] - }, - "Slider": { - "type": "object", - "properties": { - "value": { - "type": "object", - "description": "The current value of the slider. This can be a literal number ('literalNumber') or a reference to a value in the data model ('path', e.g. '/restaurant/cost').", - "properties": { - "literalNumber": { - "type": "number" - }, - "path": { - "type": "string" - } - } - }, - "minValue": { - "type": "number", - "description": "The minimum value of the slider." - }, - "maxValue": { - "type": "number", - "description": "The maximum value of the slider." - } - }, - "required": ["value"] - } - } - } - }, - "required": ["id", "component"] - } - } - }, - "required": ["surfaceId", "components"] - }, - "dataModelUpdate": { - "type": "object", - "description": "Updates the data model for a surface.", - "properties": { - "surfaceId": { - "type": "string", - "description": "The unique identifier for the UI surface this data model update applies to." - }, - "path": { - "type": "string", - "description": "An optional path to a location within the data model (e.g., '/user/name'). If omitted, or set to '/', the entire data model will be replaced." - }, - "contents": { - "type": "array", - "description": "An array of data entries. Each entry must contain a 'key' and exactly one corresponding typed 'value*' property.", - "items": { - "type": "object", - "description": "A single data entry. Exactly one 'value*' property should be provided alongside the key.", - "properties": { - "key": { - "type": "string", - "description": "The key for this data entry." - }, - "valueString": { - "type": "string" - }, - "valueNumber": { - "type": "number" - }, - "valueBoolean": { - "type": "boolean" - }, - "valueMap": { - "description": "Represents a map as an adjacency list.", - "type": "array", - "items": { - "type": "object", - "description": "One entry in the map. Exactly one 'value*' property should be provided alongside the key.", - "properties": { - "key": { - "type": "string" - }, - "valueString": { - "type": "string" - }, - "valueNumber": { - "type": "number" - }, - "valueBoolean": { - "type": "boolean" - } - }, - "required": ["key"] - } - } - }, - "required": ["key"] - } - } - }, - "required": ["contents", "surfaceId"] - }, - "deleteSurface": { - "type": "object", - "description": "Signals the client to delete the surface identified by 'surfaceId'.", - "properties": { - "surfaceId": { - "type": "string", - "description": "The unique identifier for the UI surface to be deleted." - } - }, - "required": ["surfaceId"] - } - } -} -''' diff --git a/samples/agent/adk/contact_multiple_surfaces/agent.py b/samples/agent/adk/contact_multiple_surfaces/agent.py index 29001eef0..0148a17d1 100644 --- a/samples/agent/adk/contact_multiple_surfaces/agent.py +++ b/samples/agent/adk/contact_multiple_surfaces/agent.py @@ -19,10 +19,9 @@ from typing import Any import jsonschema -from a2ui_examples import load_examples, load_floor_plan_example +from a2ui_examples import load_floor_plan_example -# Corrected imports from our new/refactored files -from a2ui_schema import A2UI_SCHEMA +from a2a.types import AgentCapabilities, AgentCard, AgentSkill from google.adk.agents.llm_agent import LlmAgent from google.adk.artifacts import InMemoryArtifactService from google.adk.memory.in_memory_memory_service import InMemoryMemoryService @@ -31,11 +30,14 @@ from google.adk.sessions import InMemorySessionService from google.genai import types from prompt_builder import ( - get_text_prompt, - get_ui_prompt, + ROLE_DESCRIPTION, + WORKFLOW_DESCRIPTION, + UI_DESCRIPTION, ) from tools import get_contact_info +from a2ui.inference.schema.manager import A2uiSchemaManager +from a2ui.inference.schema.common_modifiers import remove_strict_validation logger = logging.getLogger(__name__) @@ -48,6 +50,12 @@ class ContactAgent: def __init__(self, base_url: str, use_ui: bool = False): self.base_url = base_url self.use_ui = use_ui + self.schema_manager = A2uiSchemaManager( + version="0.8", + basic_examples_path="examples", + schema_modifiers=[remove_strict_validation], + accepts_inline_catalogs=True, + ) if use_ui else None self._agent = self._build_agent(use_ui) self._user_id = "remote_agent" self._runner = Runner( @@ -58,17 +66,29 @@ def __init__(self, base_url: str, use_ui: bool = False): memory_service=InMemoryMemoryService(), ) - # Load A2UI_SCHEMA and wrap it in an array validator for list responses - try: - single_message_schema = json.loads(A2UI_SCHEMA) - self.a2ui_schema_object = {"type": "array", "items": single_message_schema} - logger.info( - "A2UI_SCHEMA successfully loaded and wrapped in an array validator." - ) - except json.JSONDecodeError as e: - logger.error(f"CRITICAL: Failed to parse A2UI_SCHEMA: {e}") - self.a2ui_schema_object = None - # --- END MODIFICATION --- + def get_agent_card(self) -> AgentCard: + capabilities = AgentCapabilities( + streaming=True, + extensions=[self.schema_manager.get_agent_extension()], + ) + skill = AgentSkill( + id="find_contact", + name="Find Contact Tool", + description="Helps find contact information for colleagues (e.g., email, location, team).", + tags=["contact", "directory", "people", "finder"], + examples=["Who is David Chen in marketing?", "Find Sarah Lee from engineering"], + ) + + return AgentCard( + name="Contact Lookup Agent", + description="This agent helps find contact info for people in your organization.", + url=self.base_url, + version="1.0.0", + default_input_modes=ContactAgent.SUPPORTED_CONTENT_TYPES, + default_output_modes=ContactAgent.SUPPORTED_CONTENT_TYPES, + capabilities=capabilities, + skills=[skill], + ) def get_processing_message(self) -> str: return "Looking up contact information..." @@ -77,12 +97,14 @@ def _build_agent(self, use_ui: bool) -> LlmAgent: """Builds the LLM agent for the contact agent.""" LITELLM_MODEL = os.getenv("LITELLM_MODEL", "gemini/gemini-2.5-flash") - if use_ui: - examples = load_examples(self.base_url) - instruction = get_ui_prompt(self.base_url, examples) - else: - # The text prompt function also returns a complete prompt. - instruction = get_text_prompt() + instruction = self.schema_manager.generate_system_prompt( + role_description=ROLE_DESCRIPTION, + workflow_description=WORKFLOW_DESCRIPTION, + ui_description=UI_DESCRIPTION, + include_examples=True, + include_schema=True, + validate_examples=False, # Missing inline_catalogs for OrgChart and WebFrame validation + ) if use_ui else get_text_prompt() return LlmAgent( model=LiteLlm(model=LITELLM_MODEL), @@ -116,7 +138,8 @@ async def stream(self, query, session_id) -> AsyncIterable[dict[str, Any]]: current_query_text = query # Ensure schema was loaded - if self.use_ui and self.a2ui_schema_object is None: + effective_catalog = self.schema_manager.get_effective_catalog() + if self.use_ui and not effective_catalog.catalog_schema: logger.error( "--- ContactAgent.stream: A2UI_SCHEMA is not loaded. " "Cannot perform UI validation. ---" @@ -144,19 +167,6 @@ async def stream(self, query, session_id) -> AsyncIterable[dict[str, Any]]: if query.startswith("ACTION:") and "send_message" in query: logger.info("--- ContactAgent.stream: Detected send_message ACTION ---") - - # Load the action confirmation example dynamically - try: - from a2ui_examples import load_examples - # We might want to expose a specific loader for this, or just read the file here. - # Since we moved logic to a2ui_examples check if we can import the file constant or just read. - # Actually, a2ui_examples has EXAMPLE_FILES, let's just re-read using pathlib for simplicity or add a helper. - # But wait, load_examples returns the formatted string, including delimiters. - # Let's use the helper we added in a2ui_examples if possible, or just read the file. - # I didn't add a specific helper for action confirmation in a2ui_examples, but I can read the file. - pass - except ImportError: - pass # Re-implement logic to read from file from pathlib import Path @@ -288,13 +298,12 @@ async def stream(self, query, session_id) -> AsyncIterable[dict[str, Any]]: raise ValueError("Cleaned JSON string is empty.") # Validate parsed JSON against A2UI_SCHEMA - parsed_json_data = json.loads(json_string_cleaned) - logger.info( - "--- ContactAgent.stream: Validating against A2UI_SCHEMA... ---" - ) - jsonschema.validate( - instance=parsed_json_data, schema=self.a2ui_schema_object - ) + # TODO: Re-enable validation after resolving the inline catalog issue + # parsed_json_data = json.loads(json_string_cleaned) + # logger.info( + # "--- ContactAgent.stream: Validating against A2UI_SCHEMA... ---" + # ) + # effective_catalog.validator.validate(parsed_json_data) # --- End New Validation Steps --- logger.info( diff --git a/samples/agent/adk/contact_multiple_surfaces/agent_executor.py b/samples/agent/adk/contact_multiple_surfaces/agent_executor.py index 47e39fd40..eac532d83 100644 --- a/samples/agent/adk/contact_multiple_surfaces/agent_executor.py +++ b/samples/agent/adk/contact_multiple_surfaces/agent_executor.py @@ -42,9 +42,9 @@ class ContactAgentExecutor(AgentExecutor): """Contact AgentExecutor Example.""" - def __init__(self, base_url: str): + def __init__(self, agent: ContactAgent): # Instantiate the UI agent. - self.ui_agent = ContactAgent(base_url=base_url, use_ui=True) + self.ui_agent = agent async def execute( self, @@ -86,12 +86,13 @@ async def execute( query = part.root.data["request"] # Check for inline catalog - if "metadata" in part.root.data and "inlineCatalogs" in part.root.data["metadata"]: - logger.info(f" Part {i}: Found 'inlineCatalogs' in DataPart.") - inline_catalog = part.root.data["metadata"]["inlineCatalogs"] - catalog_json = json.dumps(inline_catalog) + if agent.schema_manager.accepts_inline_catalogs and "metadata" in part.root.data and "a2uiClientCapabilities" in part.root.data["metadata"]: + logger.info(f" Part {i}: Found 'a2uiClientCapabilities' in DataPart.") + client_ui_capabilities = part.root.data["metadata"]["a2uiClientCapabilities"] + catalog = agent.schema_manager.get_effective_catalog(client_ui_capabilities = client_ui_capabilities) + catalog_schema_str = catalog.render_as_llm_instructions() # Append to query so the agent sees it (simple injection) - query += f"\n\n[SYSTEM: The client supports the following custom components: {catalog_json}]" + query += f"\n\n[SYSTEM: The client supports the following custom components: {catalog_schema_str}]" else: logger.info(f" Part {i}: DataPart (data: {part.root.data})") elif isinstance(part.root, TextPart): diff --git a/samples/agent/adk/contact_multiple_surfaces/prompt_builder.py b/samples/agent/adk/contact_multiple_surfaces/prompt_builder.py index c1b8be5f6..c02c8ab2d 100644 --- a/samples/agent/adk/contact_multiple_surfaces/prompt_builder.py +++ b/samples/agent/adk/contact_multiple_surfaces/prompt_builder.py @@ -12,82 +12,38 @@ # See the License for the specific language governing permissions and # limitations under the License. -from a2ui_schema import A2UI_SCHEMA - -# This is the agent's master instruction, separate from the UI prompt formatting. -AGENT_INSTRUCTION = """ - You are a helpful contact lookup assistant. Your goal is to help users find colleagues using a rich UI. - - To achieve this, you MUST follow this logic: - - 1. **For finding contacts (e.g., "Who is Alex Jordan?"):** - a. You MUST call the `get_contact_info` tool. Extract the name and department. - b. After receiving the data: - i. If the tool returns a **single contact**, you MUST use the `CONTACT_CARD_EXAMPLE` template. - ii. If the tool returns **multiple contacts**, you MUST use the `CONTACT_LIST_EXAMPLE` template. - iii. If the tool returns an **empty list**, respond with text only and an empty JSON list: "I couldn't find anyone by that name.---a2ui_JSON---[]" - - 2. **For handling a profile view (e.g., "WHO_IS: Alex Jordan..."):** - a. You MUST call the `get_contact_info` tool with the specific name. - b. This will return a single contact. You MUST use the `CONTACT_CARD_EXAMPLE` template. - - 3. **For handling actions (e.g., "USER_WANTS_TO_EMAIL: ..."):** - a. You MUST use the `ACTION_CONFIRMATION_EXAMPLE` template. - b. Populate the `dataModelUpdate.contents` with a confirmation title and message. +import json +from a2ui.inference.schema.manager import A2uiSchemaManager +from a2ui.inference.schema.common_modifiers import remove_strict_validation + +ROLE_DESCRIPTION = "You are a helpful contact lookup assistant. Your final output MUST be a a2ui UI JSON response." + +WORKFLOW_DESCRIPTION = """ +To generate the response, you MUST follow these rules: +1. Your response MUST be in two parts, separated by the delimiter: `---a2ui_JSON---`. +2. The first part is your conversational text response (e.g., "Here is the contact you requested..."). +3. The second part is a single, raw JSON object which is a list of A2UI messages. +4. The JSON part MUST validate against the A2UI JSON SCHEMA provided below. +5. Buttons that represent the main action on a card or view (e.g., 'Follow', 'Email', 'Search') SHOULD include the `"primary": true` attribute. """ +UI_DESCRIPTION = """ +- **For finding contacts (e.g., "Who is Alex Jordan?"):** + a. You MUST call the `get_contact_info` tool. + b. If the tool returns a **single contact**, you MUST use the `MULTI_SURFACE_EXAMPLE` template. Provide BOTH the Contact Card and the Org Chart in a single response. + c. If the tool returns **multiple contacts**, you MUST use the `CONTACT_LIST_EXAMPLE` template. Populate the `dataModelUpdate.contents` with the list of contacts for the "contacts" key. + d. If the tool returns an **empty list**, respond with text only and an empty JSON list: "I couldn't find anyone by that name.---a2ui_JSON---[]" -def get_ui_prompt(base_url: str, examples: str) -> str: - """ - Constructs the full prompt with UI instructions, rules, examples, and schema. - - Args: - base_url: The base URL for resolving static assets like logos. - examples: A string containing the specific UI examples for the agent's task. - - Returns: - A formatted string to be used as the system prompt for the LLM. - """ - - # --- THIS IS THE FIX --- - # We no longer call .format() on the examples, as it breaks the JSON. - formatted_examples = examples - # --- END FIX --- - - return f""" - {AGENT_INSTRUCTION} - You are a helpful contact lookup assistant. Your final output MUST be a a2ui UI JSON response. - - To generate the response, you MUST follow these rules: - 1. Your response MUST be in two parts, separated by the delimiter: `---a2ui_JSON---`. - 2. The first part is your conversational text response (e.g., "Here is the contact you requested..."). - 3. The second part is a single, raw JSON object which is a list of A2UI messages. - 4. The JSON part MUST validate against the A2UI JSON SCHEMA provided below. - 5. Buttons that represent the main action on a card or view (e.g., 'Follow', 'Email', 'Search') SHOULD include the `"primary": true` attribute. - - --- UI TEMPLATE RULES --- - - **For finding contacts (e.g., "Who is Alex Jordan?"):** - a. You MUST call the `get_contact_info` tool. - b. If the tool returns a **single contact**, you MUST use the `MULTI_SURFACE_EXAMPLE` template. Provide BOTH the Contact Card and the Org Chart in a single response. - c. If the tool returns **multiple contacts**, you MUST use the `CONTACT_LIST_EXAMPLE` template. Populate the `dataModelUpdate.contents` with the list of contacts for the "contacts" key. - d. If the tool returns an **empty list**, respond with text only and an empty JSON list: "I couldn't find anyone by that name.---a2ui_JSON---[]" - - - **For handling a profile view (e.g., "WHO_IS: Alex Jordan..."):** - a. You MUST call the `get_contact_info` tool with the specific name. - b. This will return a single contact. You MUST use the `CONTACT_CARD_EXAMPLE` template. +- **For handling a profile view (e.g., "WHO_IS: Alex Jordan..."):** + a. You MUST call the `get_contact_info` tool with the specific name. + b. This will return a single contact. You MUST use the `CONTACT_CARD_EXAMPLE` template. - - **For handling actions (e.g., "USER_WANTS_TO_EMAIL: ..."):** - a. You MUST use the `ACTION_CONFIRMATION_EXAMPLE` template. - b. Populate the `dataModelUpdate.contents` with a confirmation title and message (e.g., title: "Email Drafted", message: "Drafting an email to Alex Jordan..."). +- **For handling actions (e.g., "USER_WANTS_TO_EMAIL: ..."):** + a. You MUST use the `ACTION_CONFIRMATION_EXAMPLE` template. + b. Populate the `dataModelUpdate.contents` with a confirmation title and message (e.g., title: "Email Drafted", message: "Drafting an email to Alex Jordan..."). - - {formatted_examples} - - ---BEGIN A2UI JSON SCHEMA--- - {A2UI_SCHEMA} - ---END A2UI JSON SCHEMA--- - """ +""" def get_text_prompt() -> str: @@ -110,11 +66,41 @@ def get_text_prompt() -> str: if __name__ == "__main__": - # Example of how to use the prompt builder + # Example of how to use the A2UI Schema Manager to generate a system prompt my_base_url = "http://localhost:8000" - from a2ui_examples import load_examples - contact_prompt = get_ui_prompt(my_base_url, load_examples(my_base_url)) + schema_manager = A2uiSchemaManager( + "0.8", basic_examples_path="examples", accepts_inline_catalogs=True, schema_modifiers=[remove_strict_validation] + ) + contact_prompt = schema_manager.generate_system_prompt( + role_description=ROLE_DESCRIPTION, + workflow_description=WORKFLOW_DESCRIPTION, + ui_description=UI_DESCRIPTION, + include_schema=True, + include_examples=True, + validate_examples=True, + ) print(contact_prompt) with open("generated_prompt.txt", "w") as f: f.write(contact_prompt) print("\nGenerated prompt saved to generated_prompt.txt") + + client_ui_capabilities_str = '{"inlineCatalogs":[{"catalogId": "inline_catalog", "components":{"OrgChart":{"type":"object","properties":{"chain":{"type":"array","items":{"type":"object","properties":{"title":{"type":"string"},"name":{"type":"string"}},"required":["title","name"]}},"action":{"$ref":"#/definitions/Action"}},"required":["chain"]},"WebFrame":{"type":"object","properties":{"url":{"type":"string"},"html":{"type":"string"},"height":{"type":"number"},"interactionMode":{"type":"string","enum":["readOnly","interactive"]},"allowedEvents":{"type":"array","items":{"type":"string"}}}}}}]}' + client_ui_capabilities = json.loads(client_ui_capabilities_str) + inline_catalog = schema_manager.get_effective_catalog( + client_ui_capabilities = client_ui_capabilities, + ) + request_prompt = inline_catalog.render_as_llm_instructions() + print(request_prompt) + with open("request_prompt.txt", "w") as f: + f.write(request_prompt) + print("\nGenerated request prompt saved to request_prompt.txt") + + basic_catalog = schema_manager.get_effective_catalog() + examples = schema_manager.load_examples( + basic_catalog, + validate=True, + ) + print(examples) + with open("examples.txt", "w") as f: + f.write(examples) + print("\nGenerated examples saved to examples.txt") diff --git a/samples/client/lit/contact/client.ts b/samples/client/lit/contact/client.ts index 0603dc992..b64dda60a 100644 --- a/samples/client/lit/contact/client.ts +++ b/samples/client/lit/contact/client.ts @@ -45,7 +45,9 @@ export class A2UIClient { const finalMessage = { ...message, metadata: { - inlineCatalogs: [catalog], + "a2uiClientCapabilities": { + "inlineCatalogs": [catalog], + }, }, }; From 4bd12e52804c51ae9b2a80d481d74154ea7b3c35 Mon Sep 17 00:00:00 2001 From: Nan Yu Date: Wed, 28 Jan 2026 20:14:56 +0000 Subject: [PATCH 6/6] Update the restaurant finder sample It updates the sample to use the A2uiSchemaManager from the a2ui-agent python SDK. Tested: - [x] The `restaurant` angular client successfully connected to the `restaurant_finder` agent and rendered the response correctly. --- .../agent/adk/restaurant_finder/__main__.py | 30 +- .../adk/restaurant_finder/a2ui_examples.py | 186 ---- samples/agent/adk/restaurant_finder/agent.py | 86 +- .../adk/restaurant_finder/agent_executor.py | 6 +- .../examples/booking_form.json | 30 + .../examples/confirmation.json | 27 + .../examples/single_column_list.json | 45 + .../examples/two_column_list.json | 56 ++ .../adk/restaurant_finder/prompt_builder.py | 838 +----------------- 9 files changed, 231 insertions(+), 1073 deletions(-) delete mode 100644 samples/agent/adk/restaurant_finder/a2ui_examples.py create mode 100644 samples/agent/adk/restaurant_finder/examples/booking_form.json create mode 100644 samples/agent/adk/restaurant_finder/examples/confirmation.json create mode 100644 samples/agent/adk/restaurant_finder/examples/single_column_list.json create mode 100644 samples/agent/adk/restaurant_finder/examples/two_column_list.json diff --git a/samples/agent/adk/restaurant_finder/__main__.py b/samples/agent/adk/restaurant_finder/__main__.py index b7114fd78..a49f986c0 100644 --- a/samples/agent/adk/restaurant_finder/__main__.py +++ b/samples/agent/adk/restaurant_finder/__main__.py @@ -19,8 +19,6 @@ from a2a.server.apps import A2AStarletteApplication from a2a.server.request_handlers import DefaultRequestHandler from a2a.server.tasks import InMemoryTaskStore -from a2a.types import AgentCapabilities, AgentCard, AgentSkill -from a2ui.extension.a2ui_extension import get_a2ui_agent_extension from agent import RestaurantAgent from agent_executor import RestaurantAgentExecutor from dotenv import load_dotenv @@ -49,39 +47,19 @@ def main(host, port): "GEMINI_API_KEY environment variable not set and GOOGLE_GENAI_USE_VERTEXAI is not TRUE." ) - capabilities = AgentCapabilities( - streaming=True, - extensions=[get_a2ui_agent_extension()], - ) - skill = AgentSkill( - id="find_restaurants", - name="Find Restaurants Tool", - description="Helps find restaurants based on user criteria (e.g., cuisine, location).", - tags=["restaurant", "finder"], - examples=["Find me the top 10 chinese restaurants in the US"], - ) - base_url = f"http://{host}:{port}" - agent_card = AgentCard( - name="Restaurant Agent", - description="This agent helps find restaurants based on user criteria.", - url=base_url, # <-- Use base_url here - version="1.0.0", - default_input_modes=RestaurantAgent.SUPPORTED_CONTENT_TYPES, - default_output_modes=RestaurantAgent.SUPPORTED_CONTENT_TYPES, - capabilities=capabilities, - skills=[skill], - ) + ui_agent = RestaurantAgent(base_url=base_url, use_ui=True) + text_agent = RestaurantAgent(base_url=base_url, use_ui=False) - agent_executor = RestaurantAgentExecutor(base_url=base_url) + agent_executor = RestaurantAgentExecutor(ui_agent, text_agent) request_handler = DefaultRequestHandler( agent_executor=agent_executor, task_store=InMemoryTaskStore(), ) server = A2AStarletteApplication( - agent_card=agent_card, http_handler=request_handler + agent_card=ui_agent.get_agent_card(), http_handler=request_handler ) import uvicorn diff --git a/samples/agent/adk/restaurant_finder/a2ui_examples.py b/samples/agent/adk/restaurant_finder/a2ui_examples.py deleted file mode 100644 index b77a685ea..000000000 --- a/samples/agent/adk/restaurant_finder/a2ui_examples.py +++ /dev/null @@ -1,186 +0,0 @@ -# Copyright 2025 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -RESTAURANT_UI_EXAMPLES = """ ----BEGIN SINGLE_COLUMN_LIST_EXAMPLE--- -[ - {{ "beginRendering": {{ "surfaceId": "default", "root": "root-column", "styles": {{ "primaryColor": "#FF0000", "font": "Roboto" }} }} }}, - {{ "surfaceUpdate": {{ - "surfaceId": "default", - "components": [ - {{ "id": "root-column", "component": {{ "Column": {{ "children": {{ "explicitList": ["title-heading", "item-list"] }} }} }} }}, - {{ "id": "title-heading", "component": {{ "Text": {{ "usageHint": "h1", "text": {{ "path": "title" }} }} }} }}, - {{ "id": "item-list", "component": {{ "List": {{ "direction": "vertical", "children": {{ "template": {{ "componentId": "item-card-template", "dataBinding": "/items" }} }} }} }} }}, - {{ "id": "item-card-template", "component": {{ "Card": {{ "child": "card-layout" }} }} }}, - {{ "id": "card-layout", "component": {{ "Row": {{ "children": {{ "explicitList": ["template-image", "card-details"] }} }} }} }}, - {{ "id": "template-image", weight: 1, "component": {{ "Image": {{ "url": {{ "path": "imageUrl" }} }} }} }}, - {{ "id": "card-details", weight: 2, "component": {{ "Column": {{ "children": {{ "explicitList": ["template-name", "template-rating", "template-detail", "template-link", "template-book-button"] }} }} }} }}, - {{ "id": "template-name", "component": {{ "Text": {{ "usageHint": "h3", "text": {{ "path": "name" }} }} }} }}, - {{ "id": "template-rating", "component": {{ "Text": {{ "text": {{ "path": "rating" }} }} }} }}, - {{ "id": "template-detail", "component": {{ "Text": {{ "text": {{ "path": "detail" }} }} }} }}, - {{ "id": "template-link", "component": {{ "Text": {{ "text": {{ "path": "infoLink" }} }} }} }}, - {{ "id": "template-book-button", "component": {{ "Button": {{ "child": "book-now-text", "primary": true, "action": {{ "name": "book_restaurant", "context": [ {{ "key": "restaurantName", "value": {{ "path": "name" }} }}, {{ "key": "imageUrl", "value": {{ "path": "imageUrl" }} }}, {{ "key": "address", "value": {{ "path": "address" }} }} ] }} }} }} }}, - {{ "id": "book-now-text", "component": {{ "Text": {{ "text": {{ "literalString": "Book Now" }} }} }} }} - ] - }} }}, - {{ "dataModelUpdate": {{ - "surfaceId": "default", - "path": "/", - "contents": [ - {{ "key": "items", "valueMap": [ - {{ "key": "item1", "valueMap": [ - {{ "key": "name", "valueString": "The Fancy Place" }}, - {{ "key": "rating", "valueNumber": 4.8 }}, - {{ "key": "detail", "valueString": "Fine dining experience" }}, - {{ "key": "infoLink", "valueString": "https://example.com/fancy" }}, - {{ "key": "imageUrl", "valueString": "https://example.com/fancy.jpg" }}, - {{ "key": "address", "valueString": "123 Main St" }} - ] }}, - {{ "key": "item2", "valueMap": [ - {{ "key": "name", "valueString": "Quick Bites" }}, - {{ "key": "rating", "valueNumber": 4.2 }}, - {{ "key": "detail", "valueString": "Casual and fast" }}, - {{ "key": "infoLink", "valueString": "https://example.com/quick" }}, - {{ "key": "imageUrl", "valueString": "https://example.com/quick.jpg" }}, - {{ "key": "address", "valueString": "456 Oak Ave" }} - ] }} - ] }} // Populate this with restaurant data - ] - }} }} -] ----END SINGLE_COLUMN_LIST_EXAMPLE--- - ----BEGIN TWO_COLUMN_LIST_EXAMPLE--- -[ - {{ "beginRendering": {{ "surfaceId": "default", "root": "root-column", "styles": {{ "primaryColor": "#FF0000", "font": "Roboto" }} }} }}, - {{ "surfaceUpdate": {{ - "surfaceId": "default", - "components": [ - {{ "id": "root-column", "component": {{ "Column": {{ "children": {{ "explicitList": ["title-heading", "restaurant-row-1"] }} }} }} }}, - {{ "id": "title-heading", "component": {{ "Text": {{ "usageHint": "h1", "text": {{ "path": "title" }} }} }} }}, - {{ "id": "restaurant-row-1", "component": {{ "Row": {{ "children": {{ "explicitList": ["item-card-1", "item-card-2"] }} }} }} }}, - {{ "id": "item-card-1", "weight": 1, "component": {{ "Card": {{ "child": "card-layout-1" }} }} }}, - {{ "id": "card-layout-1", "component": {{ "Column": {{ "children": {{ "explicitList": ["template-image-1", "card-details-1"] }} }} }} }}, - {{ "id": "template-image-1", "component": {{ "Image": {{ "url": {{ "path": "/items/0/imageUrl" }}, "width": "100%" }} }} }}, - {{ "id": "card-details-1", "component": {{ "Column": {{ "children": {{ "explicitList": ["template-name-1", "template-rating-1", "template-detail-1", "template-link-1", "template-book-button-1"] }} }} }} }}, - {{ "id": "template-name-1", "component": {{ "Text": {{ "usageHint": "h3", "text": {{ "path": "/items/0/name" }} }} }} }}, - {{ "id": "template-rating-1", "component": {{ "Text": {{ "text": {{ "path": "/items/0/rating" }} }} }} }}, - {{ "id": "template-detail-1", "component": {{ "Text": {{ "text": {{ "path": "/items/0/detail" }} }} }} }}, - {{ "id": "template-link-1", "component": {{ "Text": {{ "text": {{ "path": "/items/0/infoLink" }} }} }} }}, - {{ "id": "template-book-button-1", "component": {{ "Button": {{ "child": "book-now-text-1", "action": {{ "name": "book_restaurant", "context": [ {{ "key": "restaurantName", "value": {{ "path": "/items/0/name" }} }}, {{ "key": "imageUrl", "value": {{ "path": "/items/0/imageUrl" }} }}, {{ "key": "address", "value": {{ "path": "/items/0/address" }} }} ] }} }} }} }}, - {{ "id": "book-now-text-1", "component": {{ "Text": {{ "text": {{ "literalString": "Book Now" }} }} }} }}, - {{ "id": "item-card-2", "weight": 1, "component": {{ "Card": {{ "child": "card-layout-2" }} }} }}, - {{ "id": "card-layout-2", "component": {{ "Column": {{ "children": {{ "explicitList": ["template-image-2", "card-details-2"] }} }} }} }}, - {{ "id": "template-image-2", "component": {{ "Image": {{ "url": {{ "path": "/items/1/imageUrl" }}, "width": "100%" }} }} }}, - {{ "id": "card-details-2", "component": {{ "Column": {{ "children": {{ "explicitList": ["template-name-2", "template-rating-2", "template-detail-2", "template-link-2", "template-book-button-2"] }} }} }} }}, - {{ "id": "template-name-2", "component": {{ "Text": {{ "usageHint": "h3", "text": {{ "path": "/items/1/name" }} }} }} }}, - {{ "id": "template-rating-2", "component": {{ "Text": {{ "text": {{ "path": "/items/1/rating" }} }} }} }}, - {{ "id": "template-detail-2", "component": {{ "Text": {{ "text": {{ "path": "/items/1/detail" }} }} }} }}, - {{ "id": "template-link-2", "component": {{ "Text": {{ "text": {{ "path": "/items/1/infoLink" }} }} }} }}, - {{ "id": "template-book-button-2", "component": {{ "Button": {{ "child": "book-now-text-2", "action": {{ "name": "book_restaurant", "context": [ {{ "key": "restaurantName", "value": {{ "path": "/items/1/name" }} }}, {{ "key": "imageUrl", "value": {{ "path": "/items/1/imageUrl" }} }}, {{ "key": "address", "value": {{ "path": "/items/1/address" }} }} ] }} }} }} }}, - {{ "id": "book-now-text-2", "component": {{ "Text": {{ "text": {{ "literalString": "Book Now" }} }} }} }} - ] - }} }}, - {{ "dataModelUpdate": {{ - "surfaceId": "default", - "path": "/", - "contents": [ - {{ "key": "title", "valueString": "Top Restaurants" }}, - {{ "key": "items", "valueMap": [ - {{ "key": "item1", "valueMap": [ - {{ "key": "name", "valueString": "The Fancy Place" }}, - {{ "key": "rating", "valueNumber": 4.8 }}, - {{ "key": "detail", "valueString": "Fine dining experience" }}, - {{ "key": "infoLink", "valueString": "https://example.com/fancy" }}, - {{ "key": "imageUrl", "valueString": "https://example.com/fancy.jpg" }}, - {{ "key": "address", "valueString": "123 Main St" }} - ] }}, - {{ "key": "item2", "valueMap": [ - {{ "key": "name", "valueString": "Quick Bites" }}, - {{ "key": "rating", "valueNumber": 4.2 }}, - {{ "key": "detail", "valueString": "Casual and fast" }}, - {{ "key": "infoLink", "valueString": "https://example.com/quick" }}, - {{ "key": "imageUrl", "valueString": "https://example.com/quick.jpg" }}, - {{ "key": "address", "valueString": "456 Oak Ave" }} - ] }} - ] }} // Populate this with restaurant data - ] - }} }} -] ----END TWO_COLUMN_LIST_EXAMPLE--- - ----BEGIN BOOKING_FORM_EXAMPLE--- -[ - {{ "beginRendering": {{ "surfaceId": "booking-form", "root": "booking-form-column", "styles": {{ "primaryColor": "#FF0000", "font": "Roboto" }} }} }}, - {{ "surfaceUpdate": {{ - "surfaceId": "booking-form", - "components": [ - {{ "id": "booking-form-column", "component": {{ "Column": {{ "children": {{ "explicitList": ["booking-title", "restaurant-image", "restaurant-address", "party-size-field", "datetime-field", "dietary-field", "submit-button"] }} }} }} }}, - {{ "id": "booking-title", "component": {{ "Text": {{ "usageHint": "h2", "text": {{ "path": "title" }} }} }} }}, - {{ "id": "restaurant-image", "component": {{ "Image": {{ "url": {{ "path": "imageUrl" }} }} }} }}, - {{ "id": "restaurant-address", "component": {{ "Text": {{ "text": {{ "path": "address" }} }} }} }}, - {{ "id": "party-size-field", "component": {{ "TextField": {{ "label": {{ "literalString": "Party Size" }}, "text": {{ "path": "partySize" }}, "type": "number" }} }} }}, - {{ "id": "datetime-field", "component": {{ "DateTimeInput": {{ "label": {{ "literalString": "Date & Time" }}, "value": {{ "path": "reservationTime" }}, "enableDate": true, "enableTime": true }} }} }}, - {{ "id": "dietary-field", "component": {{ "TextField": {{ "label": {{ "literalString": "Dietary Requirements" }}, "text": {{ "path": "dietary" }} }} }} }}, - {{ "id": "submit-button", "component": {{ "Button": {{ "child": "submit-reservation-text", "action": {{ "name": "submit_booking", "context": [ {{ "key": "restaurantName", "value": {{ "path": "restaurantName" }} }}, {{ "key": "partySize", "value": {{ "path": "partySize" }} }}, {{ "key": "reservationTime", "value": {{ "path": "reservationTime" }} }}, {{ "key": "dietary", "value": {{ "path": "dietary" }} }}, {{ "key": "imageUrl", "value": {{ "path": "imageUrl" }} }} ] }} }} }} }}, - {{ "id": "submit-reservation-text", "component": {{ "Text": {{ "text": {{ "literalString": "Submit Reservation" }} }} }} }} - ] - }} }}, - {{ "dataModelUpdate": {{ - "surfaceId": "booking-form", - "path": "/", - "contents": [ - {{ "key": "title", "valueString": "Book a Table at [RestaurantName]" }}, - {{ "key": "address", "valueString": "[Restaurant Address]" }}, - {{ "key": "restaurantName", "valueString": "[RestaurantName]" }}, - {{ "key": "partySize", "valueString": "2" }}, - {{ "key": "reservationTime", "valueString": "" }}, - {{ "key": "dietary", "valueString": "" }}, - {{ "key": "imageUrl", "valueString": "" }} - ] - }} }} -] ----END BOOKING_FORM_EXAMPLE--- - ----BEGIN CONFIRMATION_EXAMPLE--- -[ - {{ "beginRendering": {{ "surfaceId": "confirmation", "root": "confirmation-card", "styles": {{ "primaryColor": "#FF0000", "font": "Roboto" }} }} }}, - {{ "surfaceUpdate": {{ - "surfaceId": "confirmation", - "components": [ - {{ "id": "confirmation-card", "component": {{ "Card": {{ "child": "confirmation-column" }} }} }}, - {{ "id": "confirmation-column", "component": {{ "Column": {{ "children": {{ "explicitList": ["confirm-title", "confirm-image", "divider1", "confirm-details", "divider2", "confirm-dietary", "divider3", "confirm-text"] }} }} }} }}, - {{ "id": "confirm-title", "component": {{ "Text": {{ "usageHint": "h2", "text": {{ "path": "title" }} }} }} }}, - {{ "id": "confirm-image", "component": {{ "Image": {{ "url": {{ "path": "imageUrl" }} }} }} }}, - {{ "id": "confirm-details", "component": {{ "Text": {{ "text": {{ "path": "bookingDetails" }} }} }} }}, - {{ "id": "confirm-dietary", "component": {{ "Text": {{ "text": {{ "path": "dietaryRequirements" }} }} }} }}, - {{ "id": "confirm-text", "component": {{ "Text": {{ "usageHint": "h5", "text": {{ "literalString": "We look forward to seeing you!" }} }} }} }}, - {{ "id": "divider1", "component": {{ "Divider": {{}} }} }}, - {{ "id": "divider2", "component": {{ "Divider": {{}} }} }}, - {{ "id": "divider3", "component": {{ "Divider": {{}} }} }} - ] - }} }}, - {{ "dataModelUpdate": {{ - "surfaceId": "confirmation", - "path": "/", - "contents": [ - {{ "key": "title", "valueString": "Booking at [RestaurantName]" }}, - {{ "key": "bookingDetails", "valueString": "[PartySize] people at [Time]" }}, - {{ "key": "dietaryRequirements", "valueString": "Dietary Requirements: [Requirements]" }}, - {{ "key": "imageUrl", "valueString": "[ImageUrl]" }} - ] - }} }} -] ----END CONFIRMATION_EXAMPLE--- -""" diff --git a/samples/agent/adk/restaurant_finder/agent.py b/samples/agent/adk/restaurant_finder/agent.py index 5283cd882..8b5e8b24c 100644 --- a/samples/agent/adk/restaurant_finder/agent.py +++ b/samples/agent/adk/restaurant_finder/agent.py @@ -19,6 +19,7 @@ from typing import Any import jsonschema +from a2a.types import AgentCapabilities, AgentCard, AgentSkill from google.adk.agents.llm_agent import LlmAgent from google.adk.artifacts import InMemoryArtifactService from google.adk.memory.in_memory_memory_service import InMemoryMemoryService @@ -27,32 +28,17 @@ from google.adk.sessions import InMemorySessionService from google.genai import types from prompt_builder import ( - A2UI_SCHEMA, - RESTAURANT_UI_EXAMPLES, get_text_prompt, - get_ui_prompt, + ROLE_DESCRIPTION, + WORKFLOW_DESCRIPTION, + UI_DESCRIPTION, ) from tools import get_restaurants +from a2ui.inference.schema.manager import A2uiSchemaManager +from a2ui.inference.schema.common_modifiers import remove_strict_validation logger = logging.getLogger(__name__) -AGENT_INSTRUCTION = """ - You are a helpful restaurant finding assistant. Your goal is to help users find and book restaurants using a rich UI. - - To achieve this, you MUST follow this logic: - - 1. **For finding restaurants:** - a. You MUST call the `get_restaurants` tool. Extract the cuisine, location, and a specific number (`count`) of restaurants from the user's query (e.g., for "top 5 chinese places", count is 5). - b. After receiving the data, you MUST follow the instructions precisely to generate the final a2ui UI JSON, using the appropriate UI example from the `prompt_builder.py` based on the number of restaurants. - - 2. **For booking a table (when you receive a query like 'USER_WANTS_TO_BOOK...'):** - a. You MUST use the appropriate UI example from `prompt_builder.py` to generate the UI, populating the `dataModelUpdate.contents` with the details from the user's query. - - 3. **For confirming a booking (when you receive a query like 'User submitted a booking...'):** - a. You MUST use the appropriate UI example from `prompt_builder.py` to generate the confirmation UI, populating the `dataModelUpdate.contents` with the final booking details. -""" - - class RestaurantAgent: """An agent that finds restaurants based on user criteria.""" @@ -61,6 +47,7 @@ class RestaurantAgent: def __init__(self, base_url: str, use_ui: bool = False): self.base_url = base_url self.use_ui = use_ui + self._schema_manager = A2uiSchemaManager("0.8", basic_examples_path="examples/", schema_modifiers=[remove_strict_validation]) if use_ui else None self._agent = self._build_agent(use_ui) self._user_id = "remote_agent" self._runner = Runner( @@ -71,22 +58,29 @@ def __init__(self, base_url: str, use_ui: bool = False): memory_service=InMemoryMemoryService(), ) - # --- MODIFICATION: Wrap the schema --- - # Load the A2UI_SCHEMA string into a Python object for validation - try: - # First, load the schema for a *single message* - single_message_schema = json.loads(A2UI_SCHEMA) + def get_agent_card(self) -> AgentCard: + capabilities = AgentCapabilities( + streaming=True, + extensions=[self._schema_manager.get_agent_extension()], + ) + skill = AgentSkill( + id="find_restaurants", + name="Find Restaurants Tool", + description="Helps find restaurants based on user criteria (e.g., cuisine, location).", + tags=["restaurant", "finder"], + examples=["Find me the top 10 chinese restaurants in the US"], + ) - # The prompt instructs the LLM to return a *list* of messages. - # Therefore, our validation schema must be an *array* of the single message schema. - self.a2ui_schema_object = {"type": "array", "items": single_message_schema} - logger.info( - "A2UI_SCHEMA successfully loaded and wrapped in an array validator." - ) - except json.JSONDecodeError as e: - logger.error(f"CRITICAL: Failed to parse A2UI_SCHEMA: {e}") - self.a2ui_schema_object = None - # --- END MODIFICATION --- + return AgentCard( + name="Restaurant Agent", + description="This agent helps find restaurants based on user criteria.", + url=self.base_url, + version="1.0.0", + default_input_modes=RestaurantAgent.SUPPORTED_CONTENT_TYPES, + default_output_modes=RestaurantAgent.SUPPORTED_CONTENT_TYPES, + capabilities=capabilities, + skills=[skill], + ) def get_processing_message(self) -> str: return "Finding restaurants that match your criteria..." @@ -95,13 +89,14 @@ def _build_agent(self, use_ui: bool) -> LlmAgent: """Builds the LLM agent for the restaurant agent.""" LITELLM_MODEL = os.getenv("LITELLM_MODEL", "gemini/gemini-2.5-flash") - if use_ui: - # Construct the full prompt with UI instructions, examples, and schema - instruction = AGENT_INSTRUCTION + get_ui_prompt( - self.base_url, RESTAURANT_UI_EXAMPLES - ) - else: - instruction = get_text_prompt() + instruction = self._schema_manager.generate_system_prompt( + role_description=ROLE_DESCRIPTION, + workflow_description=WORKFLOW_DESCRIPTION, + ui_description=UI_DESCRIPTION, + include_schema=True, + include_examples=True, + validate_examples=True, + ) if use_ui else get_text_prompt() return LlmAgent( model=LiteLlm(model=LITELLM_MODEL), @@ -135,7 +130,8 @@ async def stream(self, query, session_id) -> AsyncIterable[dict[str, Any]]: current_query_text = query # Ensure schema was loaded - if self.use_ui and self.a2ui_schema_object is None: + effective_catalog = self._schema_manager.get_effective_catalog() + if self.use_ui and not effective_catalog.catalog_schema: logger.error( "--- RestaurantAgent.stream: A2UI_SCHEMA is not loaded. " "Cannot perform UI validation. ---" @@ -235,9 +231,7 @@ async def stream(self, query, session_id) -> AsyncIterable[dict[str, Any]]: logger.info( "--- RestaurantAgent.stream: Validating against A2UI_SCHEMA... ---" ) - jsonschema.validate( - instance=parsed_json_data, schema=self.a2ui_schema_object - ) + effective_catalog.validator.validate(parsed_json_data) # --- End New Validation Steps --- logger.info( diff --git a/samples/agent/adk/restaurant_finder/agent_executor.py b/samples/agent/adk/restaurant_finder/agent_executor.py index a693e45de..d0dc5efc2 100644 --- a/samples/agent/adk/restaurant_finder/agent_executor.py +++ b/samples/agent/adk/restaurant_finder/agent_executor.py @@ -41,11 +41,11 @@ class RestaurantAgentExecutor(AgentExecutor): """Restaurant AgentExecutor Example.""" - def __init__(self, base_url: str): + def __init__(self, ui_agent: RestaurantAgent, text_agent: RestaurantAgent): # Instantiate two agents: one for UI and one for text-only. # The appropriate one will be chosen at execution time. - self.ui_agent = RestaurantAgent(base_url=base_url, use_ui=True) - self.text_agent = RestaurantAgent(base_url=base_url, use_ui=False) + self.ui_agent = ui_agent + self.text_agent = text_agent async def execute( self, diff --git a/samples/agent/adk/restaurant_finder/examples/booking_form.json b/samples/agent/adk/restaurant_finder/examples/booking_form.json new file mode 100644 index 000000000..e144b78b7 --- /dev/null +++ b/samples/agent/adk/restaurant_finder/examples/booking_form.json @@ -0,0 +1,30 @@ +[ + { "beginRendering": { "surfaceId": "booking-form", "root": "booking-form-column", "styles": { "primaryColor": "#FF0000", "font": "Roboto" } } }, + { "surfaceUpdate": { + "surfaceId": "booking-form", + "components": [ + { "id": "booking-form-column", "component": { "Column": { "children": { "explicitList": ["booking-title", "restaurant-image", "restaurant-address", "party-size-field", "datetime-field", "dietary-field", "submit-button"] } } } } , + { "id": "booking-title", "component": { "Text": { "usageHint": "h2", "text": { "path": "title" } } } }, + { "id": "restaurant-image", "component": { "Image": { "url": { "path": "imageUrl" } } } }, + { "id": "restaurant-address", "component": { "Text": { "text": { "path": "address" } } } }, + { "id": "party-size-field", "component": { "TextField": { "label": { "literalString": "Party Size" }, "text": { "path": "partySize" }, "type": "number" } } }, + { "id": "datetime-field", "component": { "DateTimeInput": { "label": { "literalString": "Date & Time" }, "value": { "path": "reservationTime" }, "enableDate": true, "enableTime": true } } }, + { "id": "dietary-field", "component": { "TextField": { "label": { "literalString": "Dietary Requirements" }, "text": { "path": "dietary" } } } }, + { "id": "submit-button", "component": { "Button": { "child": "submit-reservation-text", "action": { "name": "submit_booking", "context": [ { "key": "restaurantName", "value": { "path": "restaurantName" } }, { "key": "partySize", "value": { "path": "partySize" } }, { "key": "reservationTime", "value": { "path": "reservationTime" } }, { "key": "dietary", "value": { "path": "dietary" } }, { "key": "imageUrl", "value": { "path": "imageUrl" } } ] } } } }, + { "id": "submit-reservation-text", "component": { "Text": { "text": { "literalString": "Submit Reservation" } } } } + ] + }}, + { "dataModelUpdate": { + "surfaceId": "booking-form", + "path": "/", + "contents": [ + { "key": "title", "valueString": "Book a Table at [RestaurantName]" }, + { "key": "address", "valueString": "[Restaurant Address]" }, + { "key": "restaurantName", "valueString": "[RestaurantName]" }, + { "key": "partySize", "valueString": "2" }, + { "key": "reservationTime", "valueString": "" }, + { "key": "dietary", "valueString": "" }, + { "key": "imageUrl", "valueString": "" } + ] + }} +] \ No newline at end of file diff --git a/samples/agent/adk/restaurant_finder/examples/confirmation.json b/samples/agent/adk/restaurant_finder/examples/confirmation.json new file mode 100644 index 000000000..049e4b9c8 --- /dev/null +++ b/samples/agent/adk/restaurant_finder/examples/confirmation.json @@ -0,0 +1,27 @@ +[ + { "beginRendering": { "surfaceId": "confirmation", "root": "confirmation-card", "styles": { "primaryColor": "#FF0000", "font": "Roboto" } } }, + { "surfaceUpdate": { + "surfaceId": "confirmation", + "components": [ + { "id": "confirmation-card", "component": { "Card": { "child": "confirmation-column" } } }, + { "id": "confirm-title", "component": { "Text": { "usageHint": "h2", "text": { "path": "title" } } } }, + { "id": "confirm-image", "component": { "Image": { "url": { "path": "imageUrl" } } } }, + { "id": "confirm-details", "component": { "Text": { "text": { "path": "bookingDetails" } } } }, + { "id": "confirm-dietary", "component": { "Text": { "text": { "path": "dietaryRequirements" } } } }, + { "id": "confirm-text", "component": { "Text": { "usageHint": "h5", "text": { "literalString": "We look forward to seeing you!" } } } }, + { "id": "divider1", "component": { "Divider": {} } }, + { "id": "divider2", "component": { "Divider": {} } }, + { "id": "divider3", "component": { "Divider": {} } } + ] + }}, + { "dataModelUpdate": { + "surfaceId": "confirmation", + "path": "/", + "contents": [ + { "key": "title", "valueString": "Booking at [RestaurantName]" }, + { "key": "bookingDetails", "valueString": "[PartySize] people at [Time]" }, + { "key": "dietaryRequirements", "valueString": "Dietary Requirements: [Requirements]" }, + { "key": "imageUrl", "valueString": "[ImageUrl]" } + ] + }} +] \ No newline at end of file diff --git a/samples/agent/adk/restaurant_finder/examples/single_column_list.json b/samples/agent/adk/restaurant_finder/examples/single_column_list.json new file mode 100644 index 000000000..377d1b6dc --- /dev/null +++ b/samples/agent/adk/restaurant_finder/examples/single_column_list.json @@ -0,0 +1,45 @@ +[ + { "beginRendering": { "surfaceId": "default", "root": "root-column", "styles": { "primaryColor": "#FF0000", "font": "Roboto" } } }, + { "surfaceUpdate": { + "surfaceId": "default", + "components": [ + { "id": "root-column", "component": { "Column": { "children": { "explicitList": ["title-heading", "item-list"] } } } }, + { "id": "title-heading", "component": { "Text": { "usageHint": "h1", "text": { "path": "title" } } } }, + { "id": "item-list", "component": { "List": { "direction": "vertical", "children": { "template": { "componentId": "item-card-template", "dataBinding": "/items" } } } } }, + { "id": "item-card-template", "component": { "Card": { "child": "card-layout" } } }, + { "id": "card-layout", "component": { "Row": { "children": { "explicitList": ["template-image", "card-details"] } } } }, + { "id": "template-image", "weight": 1, "component": { "Image": { "url": { "path": "imageUrl" } } } }, + { "id": "card-details", "weight": 2, "component": { "Column": { "children": { "explicitList": ["template-name", "template-rating", "template-detail", "template-link", "template-book-button"] } } } }, + { "id": "template-name", "component": { "Text": { "usageHint": "h3", "text": { "path": "name" } } } }, + { "id": "template-rating", "component": { "Text": { "text": { "path": "rating" } } } }, + { "id": "template-detail", "component": { "Text": { "text": { "path": "detail" } } } }, + { "id": "template-link", "component": { "Text": { "text": { "path": "infoLink" } } } }, + { "id": "template-book-button", "component": { "Button": { "child": "book-now-text", "primary": true, "action": { "name": "book_restaurant", "context": [ { "key": "restaurantName", "value": { "path": "name" } }, { "key": "imageUrl", "value": { "path": "imageUrl" } }, { "key": "address", "value": { "path": "address" } } ] } } } }, + { "id": "book-now-text", "component": { "Text": { "text": { "literalString": "Book Now" } } } } + ] + }}, + {"dataModelUpdate": { + "surfaceId": "default", + "path": "/", + "contents": [ + { "key": "items", "valueMap": [ + { "key": "item1", "valueMap": [ + { "key": "name", "valueString": "The Fancy Place" }, + { "key": "rating", "valueNumber": 4.8 }, + { "key": "detail", "valueString": "Fine dining experience" }, + { "key": "infoLink", "valueString": "https://example.com/fancy" }, + { "key": "imageUrl", "valueString": "https://example.com/fancy.jpg" }, + { "key": "address", "valueString": "123 Main St" } + ] }, + { "key": "item2", "valueMap": [ + { "key": "name", "valueString": "Quick Bites" }, + { "key": "rating", "valueNumber": 4.2 }, + { "key": "detail", "valueString": "Casual and fast" }, + { "key": "infoLink", "valueString": "https://example.com/quick" }, + { "key": "imageUrl", "valueString": "https://example.com/quick.jpg" }, + { "key": "address", "valueString": "456 Oak Ave" } + ] } + ] } + ] + }} +] \ No newline at end of file diff --git a/samples/agent/adk/restaurant_finder/examples/two_column_list.json b/samples/agent/adk/restaurant_finder/examples/two_column_list.json new file mode 100644 index 000000000..51e479699 --- /dev/null +++ b/samples/agent/adk/restaurant_finder/examples/two_column_list.json @@ -0,0 +1,56 @@ +[ + { "beginRendering": { "surfaceId": "default", "root": "root-column", "styles": { "primaryColor": "#FF0000", "font": "Roboto" } } }, + { "surfaceUpdate": { + "surfaceId": "default", + "components": [ + { "id": "root-column", "component": { "Column": { "children": { "explicitList": ["title-heading", "restaurant-row-1"] } } } }, + { "id": "title-heading", "component": { "Text": { "usageHint": "h1", "text": { "path": "title" } } } }, + { "id": "restaurant-row-1", "component": { "Row": { "children": { "explicitList": ["item-card-1", "item-card-2"] } } } }, + { "id": "item-card-1", "weight": 1, "component": { "Card": { "child": "card-layout-1" } } }, + { "id": "card-layout-1", "component": { "Column": { "children": { "explicitList": ["template-image-1", "card-details-1"] } } } }, + { "id": "template-image-1", "component": { "Image": { "url": { "path": "/items/0/imageUrl" }, "width": "100%" } } }, + { "id": "card-details-1", "component": { "Column": { "children": { "explicitList": ["template-name-1", "template-rating-1", "template-detail-1", "template-link-1", "template-book-button-1"] } } } }, + { "id": "template-name-1", "component": { "Text": { "usageHint": "h3", "text": { "path": "/items/0/name" } } } }, + { "id": "template-rating-1", "component": { "Text": { "text": { "path": "/items/0/rating" } } } }, + { "id": "template-detail-1", "component": { "Text": { "text": { "path": "/items/0/detail" } } } }, + { "id": "template-link-1", "component": { "Text": { "text": { "path": "/items/0/infoLink" } } } }, + { "id": "template-book-button-1", "component": { "Button": { "child": "book-now-text-1", "action": { "name": "book_restaurant", "context": [ { "key": "restaurantName", "value": { "path": "/items/0/name" } }, { "key": "imageUrl", "value": { "path": "/items/0/imageUrl" } }, { "key": "address", "value": { "path": "/items/0/address" } } ] } } } }, + { "id": "book-now-text-1", "component": { "Text": { "text": { "literalString": "Book Now" } } } }, + { "id": "item-card-2", "weight": 1, "component": { "Card": { "child": "card-layout-2" } } }, + { "id": "card-layout-2", "component": { "Column": { "children": { "explicitList": ["template-image-2", "card-details-2"] } } } }, + { "id": "template-image-2", "component": { "Image": { "url": { "path": "/items/1/imageUrl" }, "width": "100%" } } }, + { "id": "card-details-2", "component": { "Column": { "children": { "explicitList": ["template-name-2", "template-rating-2", "template-detail-2", "template-link-2", "template-book-button-2"] } } } }, + { "id": "template-name-2", "component": { "Text": { "usageHint": "h3", "text": { "path": "/items/1/name" } } } }, + { "id": "template-rating-2", "component": { "Text": { "text": { "path": "/items/1/rating" } } } }, + { "id": "template-detail-2", "component": { "Text": { "text": { "path": "/items/1/detail" } } } }, + { "id": "template-link-2", "component": { "Text": { "text": { "path": "/items/1/infoLink" } } } }, + { "id": "template-book-button-2", "component": { "Button": { "child": "book-now-text-2", "action": { "name": "book_restaurant", "context": [ { "key": "restaurantName", "value": { "path": "/items/1/name" } }, { "key": "imageUrl", "value": { "path": "/items/1/imageUrl" } }, { "key": "address", "value": { "path": "/items/1/address" } } ] } } } }, + { "id": "book-now-text-2", "component": { "Text": { "text": { "literalString": "Book Now" } } } } + ] + }}, + {"dataModelUpdate": { + "surfaceId": "default", + "path": "/", + "contents": [ + {"key": "title", "valueString": "Top Restaurants" }, + {"key": "items", "valueMap": [ + {"key": "item1", "valueMap": [ + {"key": "name", "valueString": "The Fancy Place" }, + {"key": "rating", "valueNumber": 4.8 }, + {"key": "detail", "valueString": "Fine dining experience" }, + {"key": "infoLink", "valueString": "https://example.com/fancy" }, + {"key": "imageUrl", "valueString": "https://example.com/fancy.jpg" }, + {"key": "address", "valueString": "123 Main St" } + ]}, + {"key": "item2", "valueMap": [ + {"key": "name", "valueString": "Quick Bites" }, + {"key": "rating", "valueNumber": 4.2 }, + {"key": "detail", "valueString": "Casual and fast" }, + {"key": "infoLink", "valueString": "https://example.com/quick" }, + {"key": "imageUrl", "valueString": "https://example.com/quick.jpg" }, + {"key": "address", "valueString": "456 Oak Ave" } + ] } + ] } + ] + }} +] \ No newline at end of file diff --git a/samples/agent/adk/restaurant_finder/prompt_builder.py b/samples/agent/adk/restaurant_finder/prompt_builder.py index 1cc8445e9..11a93ea3c 100644 --- a/samples/agent/adk/restaurant_finder/prompt_builder.py +++ b/samples/agent/adk/restaurant_finder/prompt_builder.py @@ -12,818 +12,26 @@ # See the License for the specific language governing permissions and # limitations under the License. -# The A2UI schema remains constant for all A2UI responses. -A2UI_SCHEMA = r''' -{ - "title": "A2UI Message Schema", - "description": "Describes a JSON payload for an A2UI (Agent to UI) message, which is used to dynamically construct and update user interfaces. A message MUST contain exactly ONE of the action properties: 'beginRendering', 'surfaceUpdate', 'dataModelUpdate', or 'deleteSurface'.", - "type": "object", - "properties": { - "beginRendering": { - "type": "object", - "description": "Signals the client to begin rendering a surface with a root component and specific styles.", - "properties": { - "surfaceId": { - "type": "string", - "description": "The unique identifier for the UI surface to be rendered." - }, - "root": { - "type": "string", - "description": "The ID of the root component to render." - }, - "styles": { - "type": "object", - "description": "Styling information for the UI.", - "properties": { - "font": { - "type": "string", - "description": "The primary font for the UI." - }, - "primaryColor": { - "type": "string", - "description": "The primary UI color as a hexadecimal code (e.g., '#00BFFF').", - "pattern": "^#[0-9a-fA-F]{6}$" - } - } - } - }, - "required": ["root", "surfaceId"] - }, - "surfaceUpdate": { - "type": "object", - "description": "Updates a surface with a new set of components.", - "properties": { - "surfaceId": { - "type": "string", - "description": "The unique identifier for the UI surface to be updated. If you are adding a new surface this *must* be a new, unique identified that has never been used for any existing surfaces shown." - }, - "components": { - "type": "array", - "description": "A list containing all UI components for the surface.", - "minItems": 1, - "items": { - "type": "object", - "description": "Represents a *single* component in a UI widget tree. This component could be one of many supported types.", - "properties": { - "id": { - "type": "string", - "description": "The unique identifier for this component." - }, - "weight": { - "type": "number", - "description": "The relative weight of this component within a Row or Column. This corresponds to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." - }, - "component": { - "type": "object", - "description": "A wrapper object that MUST contain exactly one key, which is the name of the component type (e.g., 'Heading'). The value is an object containing the properties for that specific component.", - "properties": { - "Text": { - "type": "object", - "properties": { - "text": { - "type": "object", - "description": "The text content to display. This can be a literal string or a reference to a value in the data model ('path', e.g., '/doc/title'). While simple Markdown formatting is supported (i.e. without HTML, images, or links), utilizing dedicated UI components is generally preferred for a richer and more structured presentation.", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "usageHint": { - "type": "string", - "description": "A hint for the base text style. One of:\n- `h1`: Largest heading.\n- `h2`: Second largest heading.\n- `h3`: Third largest heading.\n- `h4`: Fourth largest heading.\n- `h5`: Fifth largest heading.\n- `caption`: Small text for captions.\n- `body`: Standard body text.", - "enum": [ - "h1", - "h2", - "h3", - "h4", - "h5", - "caption", - "body" - ] - } - }, - "required": ["text"] - }, - "Image": { - "type": "object", - "properties": { - "url": { - "type": "object", - "description": "The URL of the image to display. This can be a literal string ('literal') or a reference to a value in the data model ('path', e.g. '/thumbnail/url').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "fit": { - "type": "string", - "description": "Specifies how the image should be resized to fit its container. This corresponds to the CSS 'object-fit' property.", - "enum": [ - "contain", - "cover", - "fill", - "none", - "scale-down" - ] - }, - "usageHint": { - "type": "string", - "description": "A hint for the image size and style. One of:\n- `icon`: Small square icon.\n- `avatar`: Circular avatar image.\n- `smallFeature`: Small feature image.\n- `mediumFeature`: Medium feature image.\n- `largeFeature`: Large feature image.\n- `header`: Full-width, full bleed, header image.", - "enum": [ - "icon", - "avatar", - "smallFeature", - "mediumFeature", - "largeFeature", - "header" - ] - } - }, - "required": ["url"] - }, - "Icon": { - "type": "object", - "properties": { - "name": { - "type": "object", - "description": "The name of the icon to display. This can be a literal string or a reference to a value in the data model ('path', e.g. '/form/submit').", - "properties": { - "literalString": { - "type": "string", - "enum": [ - "accountCircle", - "add", - "arrowBack", - "arrowForward", - "attachFile", - "calendarToday", - "call", - "camera", - "check", - "close", - "delete", - "download", - "edit", - "event", - "error", - "favorite", - "favoriteOff", - "folder", - "help", - "home", - "info", - "locationOn", - "lock", - "lockOpen", - "mail", - "menu", - "moreVert", - "moreHoriz", - "notificationsOff", - "notifications", - "payment", - "person", - "phone", - "photo", - "print", - "refresh", - "search", - "send", - "settings", - "share", - "shoppingCart", - "star", - "starHalf", - "starOff", - "upload", - "visibility", - "visibilityOff", - "warning" - ] - }, - "path": { - "type": "string" - } - } - } - }, - "required": ["name"] - }, - "Video": { - "type": "object", - "properties": { - "url": { - "type": "object", - "description": "The URL of the video to display. This can be a literal string or a reference to a value in the data model ('path', e.g. '/video/url').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - } - }, - "required": ["url"] - }, - "AudioPlayer": { - "type": "object", - "properties": { - "url": { - "type": "object", - "description": "The URL of the audio to be played. This can be a literal string ('literal') or a reference to a value in the data model ('path', e.g. '/song/url').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "description": { - "type": "object", - "description": "A description of the audio, such as a title or summary. This can be a literal string or a reference to a value in the data model ('path', e.g. '/song/title').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - } - }, - "required": ["url"] - }, - "Row": { - "type": "object", - "properties": { - "children": { - "type": "object", - "description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.", - "properties": { - "explicitList": { - "type": "array", - "items": { - "type": "string" - } - }, - "template": { - "type": "object", - "description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.", - "properties": { - "componentId": { - "type": "string" - }, - "dataBinding": { - "type": "string" - } - }, - "required": ["componentId", "dataBinding"] - } - } - }, - "distribution": { - "type": "string", - "description": "Defines the arrangement of children along the main axis (horizontally). This corresponds to the CSS 'justify-content' property.", - "enum": [ - "center", - "end", - "spaceAround", - "spaceBetween", - "spaceEvenly", - "start" - ] - }, - "alignment": { - "type": "string", - "description": "Defines the alignment of children along the cross axis (vertically). This corresponds to the CSS 'align-items' property.", - "enum": ["start", "center", "end", "stretch"] - } - }, - "required": ["children"] - }, - "Column": { - "type": "object", - "properties": { - "children": { - "type": "object", - "description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.", - "properties": { - "explicitList": { - "type": "array", - "items": { - "type": "string" - } - }, - "template": { - "type": "object", - "description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.", - "properties": { - "componentId": { - "type": "string" - }, - "dataBinding": { - "type": "string" - } - }, - "required": ["componentId", "dataBinding"] - } - } - }, - "distribution": { - "type": "string", - "description": "Defines the arrangement of children along the main axis (vertically). This corresponds to the CSS 'justify-content' property.", - "enum": [ - "start", - "center", - "end", - "spaceBetween", - "spaceAround", - "spaceEvenly" - ] - }, - "alignment": { - "type": "string", - "description": "Defines the alignment of children along the cross axis (horizontally). This corresponds to the CSS 'align-items' property.", - "enum": ["center", "end", "start", "stretch"] - } - }, - "required": ["children"] - }, - "List": { - "type": "object", - "properties": { - "children": { - "type": "object", - "description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.", - "properties": { - "explicitList": { - "type": "array", - "items": { - "type": "string" - } - }, - "template": { - "type": "object", - "description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.", - "properties": { - "componentId": { - "type": "string" - }, - "dataBinding": { - "type": "string" - } - }, - "required": ["componentId", "dataBinding"] - } - } - }, - "direction": { - "type": "string", - "description": "The direction in which the list items are laid out.", - "enum": ["vertical", "horizontal"] - }, - "alignment": { - "type": "string", - "description": "Defines the alignment of children along the cross axis.", - "enum": ["start", "center", "end", "stretch"] - } - }, - "required": ["children"] - }, - "Card": { - "type": "object", - "properties": { - "child": { - "type": "string", - "description": "The ID of the component to be rendered inside the card." - } - }, - "required": ["child"] - }, - "Tabs": { - "type": "object", - "properties": { - "tabItems": { - "type": "array", - "description": "An array of objects, where each object defines a tab with a title and a child component.", - "items": { - "type": "object", - "properties": { - "title": { - "type": "object", - "description": "The tab title. Defines the value as either a literal value or a path to data model value (e.g. '/options/title').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "child": { - "type": "string" - } - }, - "required": ["title", "child"] - } - } - }, - "required": ["tabItems"] - }, - "Divider": { - "type": "object", - "properties": { - "axis": { - "type": "string", - "description": "The orientation of the divider.", - "enum": ["horizontal", "vertical"] - } - } - }, - "Modal": { - "type": "object", - "properties": { - "entryPointChild": { - "type": "string", - "description": "The ID of the component that opens the modal when interacted with (e.g., a button)." - }, - "contentChild": { - "type": "string", - "description": "The ID of the component to be displayed inside the modal." - } - }, - "required": ["entryPointChild", "contentChild"] - }, - "Button": { - "type": "object", - "properties": { - "child": { - "type": "string", - "description": "The ID of the component to display in the button, typically a Text component." - }, - "primary": { - "type": "boolean", - "description": "Indicates if this button should be styled as the primary action." - }, - "action": { - "type": "object", - "description": "The client-side action to be dispatched when the button is clicked. It includes the action's name and an optional context payload.", - "properties": { - "name": { - "type": "string" - }, - "context": { - "type": "array", - "items": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "value": { - "type": "object", - "description": "Defines the value to be included in the context as either a literal value or a path to a data model value (e.g. '/user/name').", - "properties": { - "path": { - "type": "string" - }, - "literalString": { - "type": "string" - }, - "literalNumber": { - "type": "number" - }, - "literalBoolean": { - "type": "boolean" - } - } - } - }, - "required": ["key", "value"] - } - } - }, - "required": ["name"] - } - }, - "required": ["child", "action"] - }, - "CheckBox": { - "type": "object", - "properties": { - "label": { - "type": "object", - "description": "The text to display next to the checkbox. Defines the value as either a literal value or a path to data model ('path', e.g. '/option/label').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "value": { - "type": "object", - "description": "The current state of the checkbox (true for checked, false for unchecked). This can be a literal boolean ('literalBoolean') or a reference to a value in the data model ('path', e.g. '/filter/open').", - "properties": { - "literalBoolean": { - "type": "boolean" - }, - "path": { - "type": "string" - } - } - } - }, - "required": ["label", "value"] - }, - "TextField": { - "type": "object", - "properties": { - "label": { - "type": "object", - "description": "The text label for the input field. This can be a literal string or a reference to a value in the data model ('path, e.g. '/user/name').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "text": { - "type": "object", - "description": "The value of the text field. This can be a literal string or a reference to a value in the data model ('path', e.g. '/user/name').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "textFieldType": { - "type": "string", - "description": "The type of input field to display.", - "enum": [ - "date", - "longText", - "number", - "shortText", - "obscured" - ] - }, - "validationRegexp": { - "type": "string", - "description": "A regular expression used for client-side validation of the input." - } - }, - "required": ["label"] - }, - "DateTimeInput": { - "type": "object", - "properties": { - "value": { - "type": "object", - "description": "The selected date and/or time value in ISO 8601 format. This can be a literal string ('literalString') or a reference to a value in the data model ('path', e.g. '/user/dob').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "enableDate": { - "type": "boolean", - "description": "If true, allows the user to select a date." - }, - "enableTime": { - "type": "boolean", - "description": "If true, allows the user to select a time." - } - }, - "required": ["value"] - }, - "MultipleChoice": { - "type": "object", - "properties": { - "selections": { - "type": "object", - "description": "The currently selected values for the component. This can be a literal array of strings or a path to an array in the data model('path', e.g. '/hotel/options').", - "properties": { - "literalArray": { - "type": "array", - "items": { - "type": "string" - } - }, - "path": { - "type": "string" - } - } - }, - "options": { - "type": "array", - "description": "An array of available options for the user to choose from.", - "items": { - "type": "object", - "properties": { - "label": { - "type": "object", - "description": "The text to display for this option. This can be a literal string or a reference to a value in the data model (e.g. '/option/label').", - "properties": { - "literalString": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "value": { - "type": "string", - "description": "The value to be associated with this option when selected." - } - }, - "required": ["label", "value"] - } - }, - "maxAllowedSelections": { - "type": "integer", - "description": "The maximum number of options that the user is allowed to select." - } - }, - "required": ["selections", "options"] - }, - "Slider": { - "type": "object", - "properties": { - "value": { - "type": "object", - "description": "The current value of the slider. This can be a literal number ('literalNumber') or a reference to a value in the data model ('path', e.g. '/restaurant/cost').", - "properties": { - "literalNumber": { - "type": "number" - }, - "path": { - "type": "string" - } - } - }, - "minValue": { - "type": "number", - "description": "The minimum value of the slider." - }, - "maxValue": { - "type": "number", - "description": "The maximum value of the slider." - } - }, - "required": ["value"] - } - } - } - }, - "required": ["id", "component"] - } - } - }, - "required": ["surfaceId", "components"] - }, - "dataModelUpdate": { - "type": "object", - "description": "Updates the data model for a surface.", - "properties": { - "surfaceId": { - "type": "string", - "description": "The unique identifier for the UI surface this data model update applies to." - }, - "path": { - "type": "string", - "description": "An optional path to a location within the data model (e.g., '/user/name'). If omitted, or set to '/', the entire data model will be replaced." - }, - "contents": { - "type": "array", - "description": "An array of data entries. Each entry must contain a 'key' and exactly one corresponding typed 'value*' property.", - "items": { - "type": "object", - "description": "A single data entry. Exactly one 'value*' property should be provided alongside the key.", - "properties": { - "key": { - "type": "string", - "description": "The key for this data entry." - }, - "valueString": { - "type": "string" - }, - "valueNumber": { - "type": "number" - }, - "valueBoolean": { - "type": "boolean" - }, - "valueMap": { - "description": "Represents a map as an adjacency list.", - "type": "array", - "items": { - "type": "object", - "description": "One entry in the map. Exactly one 'value*' property should be provided alongside the key.", - "properties": { - "key": { - "type": "string" - }, - "valueString": { - "type": "string" - }, - "valueNumber": { - "type": "number" - }, - "valueBoolean": { - "type": "boolean" - } - }, - "required": ["key"] - } - } - }, - "required": ["key"] - } - } - }, - "required": ["contents", "surfaceId"] - }, - "deleteSurface": { - "type": "object", - "description": "Signals the client to delete the surface identified by 'surfaceId'.", - "properties": { - "surfaceId": { - "type": "string", - "description": "The unique identifier for the UI surface to be deleted." - } - }, - "required": ["surfaceId"] - } - } -} -''' +from a2ui.inference.schema.manager import A2uiSchemaManager +from a2ui.inference.schema.common_modifiers import remove_strict_validation -from a2ui_examples import RESTAURANT_UI_EXAMPLES +ROLE_DESCRIPTION = "You are a helpful restaurant finding assistant. Your final output MUST be a a2ui UI JSON response." +WORKFLOW_DESCRIPTION = """ +To generate the response, you MUST follow these rules: +1. Your response MUST be in two parts, separated by the delimiter: `---a2ui_JSON---`. +2. The first part is your conversational text response. +3. The second part is a single, raw JSON object which is a list of A2UI messages. +4. The JSON part MUST validate against the A2UI JSON SCHEMA provided below. +""" -def get_ui_prompt(base_url: str, examples: str) -> str: - """ - Constructs the full prompt with UI instructions, rules, examples, and schema. - - Args: - base_url: The base URL for resolving static assets like logos. - examples: A string containing the specific UI examples for the agent's task. - - Returns: - A formatted string to be used as the system prompt for the LLM. - """ - # The f-string substitution for base_url happens here, at runtime. - formatted_examples = examples.format(base_url=base_url) - - return f""" - You are a helpful restaurant finding assistant. Your final output MUST be a a2ui UI JSON response. - - To generate the response, you MUST follow these rules: - 1. Your response MUST be in two parts, separated by the delimiter: `---a2ui_JSON---`. - 2. The first part is your conversational text response. - 3. The second part is a single, raw JSON object which is a list of A2UI messages. - 4. The JSON part MUST validate against the A2UI JSON SCHEMA provided below. - - --- UI TEMPLATE RULES --- - - If the query is for a list of restaurants, use the restaurant data you have already received from the `get_restaurants` tool to populate the `dataModelUpdate.contents` array (e.g., as a `valueMap` for the "items" key). - - If the number of restaurants is 5 or fewer, you MUST use the `SINGLE_COLUMN_LIST_EXAMPLE` template. - - If the number of restaurants is more than 5, you MUST use the `TWO_COLUMN_LIST_EXAMPLE` template. - - If the query is to book a restaurant (e.g., "USER_WANTS_TO_BOOK..."), you MUST use the `BOOKING_FORM_EXAMPLE` template. - - If the query is a booking submission (e.g., "User submitted a booking..."), you MUST use the `CONFIRMATION_EXAMPLE` template. - - {formatted_examples} - - ---BEGIN A2UI JSON SCHEMA--- - {A2UI_SCHEMA} - ---END A2UI JSON SCHEMA--- - """ +UI_DESCRIPTION = """ +- If the query is for a list of restaurants, use the restaurant data you have already received from the `get_restaurants` tool to populate the `dataModelUpdate.contents` array (e.g., as a `valueMap` for the "items" key). +- If the number of restaurants is 5 or fewer, you MUST use the `SINGLE_COLUMN_LIST_EXAMPLE` template. +- If the number of restaurants is more than 5, you MUST use the `TWO_COLUMN_LIST_EXAMPLE` template. +- If the query is to book a restaurant (e.g., "USER_WANTS_TO_BOOK..."), you MUST use the `BOOKING_FORM_EXAMPLE` template. +- If the query is a booking submission (e.g., "User submitted a booking..."), you MUST use the `CONFIRMATION_EXAMPLE` template. +""" def get_text_prompt() -> str: @@ -847,14 +55,20 @@ def get_text_prompt() -> str: if __name__ == "__main__": - # Example of how to use the prompt builder + # Example of how to use the A2UI Schema Manager to generate a system prompt # In your actual application, you would call this from your main agent logic. - my_base_url = "http://localhost:8000" # You can now easily construct a prompt with the relevant examples. # For a different agent (e.g., a flight booker), you would pass in # different examples but use the same `get_ui_prompt` function. - restaurant_prompt = get_ui_prompt(my_base_url, RESTAURANT_UI_EXAMPLES) + restaurant_prompt = A2uiSchemaManager("0.8", basic_examples_path="examples/", schema_modifiers=[remove_strict_validation]).generate_system_prompt( + role_description=ROLE_DESCRIPTION, + workflow_description=WORKFLOW_DESCRIPTION, + ui_description=UI_DESCRIPTION, + include_schema=True, + include_examples=True, + validate_examples=True, + ) print(restaurant_prompt)