diff --git a/a2a_agents/python/a2ui_agent/src/a2ui/extension/send_a2ui_to_client_toolset.py b/a2a_agents/python/a2ui_agent/src/a2ui/extension/send_a2ui_to_client_toolset.py index 6980a39d7..bd480ea8f 100644 --- a/a2a_agents/python/a2ui_agent/src/a2ui/extension/send_a2ui_to_client_toolset.py +++ b/a2a_agents/python/a2ui_agent/src/a2ui/extension/send_a2ui_to_client_toolset.py @@ -39,16 +39,16 @@ ```python # Simple boolean and dict - toolset = SendA2uiToClientToolset(a2ui_enabled=True, a2ui_schema=MY_SCHEMA) + toolset = SendA2uiToClientToolset(a2ui_enabled=True, a2ui_catalog=MY_CATALOG) # Async providers async def check_enabled(ctx: ReadonlyContext) -> bool: return await some_condition(ctx) - async def get_schema(ctx: ReadonlyContext) -> dict[str, Any]: - return await fetch_schema(ctx) + async def get_catalog(ctx: ReadonlyContext) -> A2uiCatalog: + return await fetch_catalog(ctx) - toolset = SendA2uiToClientToolset(a2ui_enabled=check_enabled, a2ui_schema=get_schema) + toolset = SendA2uiToClientToolset(a2ui_enabled=check_enabled, a2ui_catalog=get_catalog) ``` 2. Integration with Agent: @@ -60,7 +60,7 @@ async def get_schema(ctx: ReadonlyContext) -> dict[str, Any]: tools=[ SendA2uiToClientToolset( a2ui_enabled=True, - a2ui_schema=MY_SCHEMA + a2ui_catalog=MY_CATALOG ) ] ) @@ -87,8 +87,7 @@ async def get_schema(ctx: ReadonlyContext) -> dict[str, Any]: from a2a import types as a2a_types from a2ui.extension.a2ui_extension import create_a2ui_part -from a2ui.extension.a2ui_schema_utils import wrap_as_json_array -from a2ui.extension.validation import validate_a2ui_json +from a2ui.inference.schema.catalog import A2uiCatalog from google.adk.a2a.converters import part_converter from google.adk.agents.readonly_context import ReadonlyContext from google.adk.models import LlmRequest @@ -103,8 +102,11 @@ async def get_schema(ctx: ReadonlyContext) -> dict[str, Any]: A2uiEnabledProvider: TypeAlias = Callable[ [ReadonlyContext], Union[bool, Awaitable[bool]] ] -A2uiSchemaProvider: TypeAlias = Callable[ - [ReadonlyContext], Union[dict[str, Any], Awaitable[dict[str, Any]]] +A2uiCatalogProvider: TypeAlias = Callable[ + [ReadonlyContext], Union[A2uiCatalog, Awaitable[A2uiCatalog]] +] +A2uiExamplesProvider: TypeAlias = Callable[ + [ReadonlyContext], Union[str, Awaitable[str]] ] @@ -115,11 +117,12 @@ class SendA2uiToClientToolset(base_toolset.BaseToolset): def __init__( self, a2ui_enabled: Union[bool, A2uiEnabledProvider], - a2ui_schema: Union[dict[str, Any], A2uiSchemaProvider], + a2ui_catalog: Union[A2uiCatalog, A2uiCatalogProvider], + a2ui_examples: Union[str, A2uiExamplesProvider], ): super().__init__() self._a2ui_enabled = a2ui_enabled - self._ui_tools = [self._SendA2uiJsonToClientTool(a2ui_schema)] + self._ui_tools = [self._SendA2uiJsonToClientTool(a2ui_catalog, a2ui_examples)] async def _resolve_a2ui_enabled(self, ctx: ReadonlyContext) -> bool: """The resolved self.a2ui_enabled field to construct instruction for this agent. @@ -166,8 +169,13 @@ class _SendA2uiJsonToClientTool(BaseTool): A2UI_JSON_ARG_NAME = "a2ui_json" TOOL_ERROR_KEY = "error" - def __init__(self, a2ui_schema: Union[dict[str, Any], A2uiSchemaProvider]): - self._a2ui_schema = a2ui_schema + def __init__( + self, + a2ui_catalog: Union[A2uiCatalog, A2uiCatalogProvider], + a2ui_examples: Union[str, A2uiExamplesProvider], + ): + self._a2ui_catalog = a2ui_catalog + self._a2ui_examples = a2ui_examples super().__init__( name=self.TOOL_NAME, description=( @@ -197,34 +205,39 @@ def _get_declaration(self) -> genai_types.FunctionDeclaration | None: ), ) - async def _resolve_a2ui_schema(self, ctx: ReadonlyContext) -> dict[str, Any]: - """The resolved self.a2ui_schema field to construct instruction for this agent. + async def _resolve_a2ui_examples(self, ctx: ReadonlyContext) -> str: + """The resolved self.a2ui_examples field to construct instruction for this agent. Args: ctx: The ReadonlyContext to resolve the provider with. Returns: - The A2UI schema to send to the client. + The A2UI examples string. """ - if isinstance(self._a2ui_schema, dict): - return self._a2ui_schema + if isinstance(self._a2ui_examples, str): + return self._a2ui_examples else: - a2ui_schema = self._a2ui_schema(ctx) - if inspect.isawaitable(a2ui_schema): - a2ui_schema = await a2ui_schema - return a2ui_schema + a2ui_examples = self._a2ui_examples(ctx) + if inspect.isawaitable(a2ui_examples): + a2ui_examples = await a2ui_examples + return a2ui_examples - async def get_a2ui_schema(self, ctx: ReadonlyContext) -> dict[str, Any]: - """Retrieves and wraps the A2UI schema. + async def _resolve_a2ui_catalog(self, ctx: ReadonlyContext) -> A2uiCatalog: + """The resolved self.a2ui_catalog field to construct instruction for this agent. Args: - ctx: The ReadonlyContext for resolving the schema. + ctx: The ReadonlyContext to resolve the provider with. Returns: - The wrapped A2UI schema. + The A2UI catalog object. """ - a2ui_schema = await self._resolve_a2ui_schema(ctx) - return wrap_as_json_array(a2ui_schema) + if isinstance(self._a2ui_catalog, A2uiCatalog): + return self._a2ui_catalog + else: + a2ui_catalog = self._a2ui_catalog(ctx) + if inspect.isawaitable(a2ui_catalog): + a2ui_catalog = await a2ui_catalog + return a2ui_catalog async def process_llm_request( self, *, tool_context: ToolContext, llm_request: LlmRequest @@ -233,15 +246,14 @@ async def process_llm_request( tool_context=tool_context, llm_request=llm_request ) - a2ui_schema = await self.get_a2ui_schema(tool_context) + a2ui_catalog = await self._resolve_a2ui_catalog(tool_context) - llm_request.append_instructions([f""" ----BEGIN A2UI JSON SCHEMA--- -{json.dumps(a2ui_schema)} ----END A2UI JSON SCHEMA--- -"""]) + instruction = a2ui_catalog.render_as_llm_instructions() + examples = await self._resolve_a2ui_examples(tool_context) - logger.info("Added a2ui_schema to system instructions") + llm_request.append_instructions([instruction, examples]) + + logger.info("Added A2UI schema and examples to system instructions") async def run_async( self, *, args: dict[str, Any], tool_context: ToolContext @@ -254,44 +266,8 @@ async def run_async( f" arg {self.A2UI_JSON_ARG_NAME} " ) - a2ui_schema = await self.get_a2ui_schema(tool_context) - - try: - # Attempt to parse and validate - a2ui_json_payload = json.loads(a2ui_json) - - # Auto-wrap single object in list - if not isinstance(a2ui_json_payload, list): - logger.info( - "Received a single JSON object, wrapping in a list for validation." - ) - a2ui_json_payload = [a2ui_json_payload] - - jsonschema.validate(instance=a2ui_json_payload, schema=a2ui_schema) - - except (jsonschema.exceptions.ValidationError, json.JSONDecodeError) as e: - logger.warning(f"Initial A2UI JSON validation failed: {e}") - - # Run Fixer - fixed_a2ui_json = re.sub(r",(?=\s*[\]}])", "", a2ui_json) - - if fixed_a2ui_json != a2ui_json: - # Emit Warning - logger.warning("Detected trailing commas in LLM output; applied autofix.") - - # Re-parse and Re-validate - a2ui_json_payload = json.loads(fixed_a2ui_json) - - # Auto-wrap single object in list - if not isinstance(a2ui_json_payload, list): - logger.info( - "Received a single JSON object, wrapping in a list for validation." - ) - a2ui_json_payload = [a2ui_json_payload] - - jsonschema.validate(instance=a2ui_json_payload, schema=a2ui_schema) - else: - raise e + a2ui_catalog = await self._resolve_a2ui_catalog(tool_context) + a2ui_json_payload = a2ui_catalog.payload_fixer.fix(a2ui_json) logger.info( f"Validated call to tool {self.TOOL_NAME} with {self.A2UI_JSON_ARG_NAME}" diff --git a/a2a_agents/python/a2ui_agent/src/a2ui/extension/validation.py b/a2a_agents/python/a2ui_agent/src/a2ui/extension/validation.py deleted file mode 100644 index db98e16f9..000000000 --- a/a2a_agents/python/a2ui_agent/src/a2ui/extension/validation.py +++ /dev/null @@ -1,290 +0,0 @@ -from typing import Any, Dict, Iterator, List, Set, Tuple, Union -import jsonschema -import re - -# RFC 6901 compliant regex for JSON Pointer -JSON_POINTER_PATTERN = re.compile(r"^(?:\/(?:[^~\/]|~[01])*)*$") - -# Recursion Limits -MAX_GLOBAL_DEPTH = 50 -MAX_FUNC_CALL_DEPTH = 5 - -# Constants -COMPONENTS = "components" -ID = "id" -COMPONENT_PROPERTIES = "componentProperties" -ROOT = "root" -PATH = "path" -FUNCTION_CALL = "functionCall" -CALL = "call" -ARGS = "args" - - -def validate_a2ui_json( - a2ui_json: Union[Dict[str, Any], List[Any]], a2ui_schema: Dict[str, Any] -) -> None: - """ - Validates the A2UI JSON payload against the provided schema and checks for integrity. - - Checks performed: - 1. **JSON Schema Validation**: Ensures payload adheres to the A2UI schema. - 2. **Component Integrity**: - - All component IDs are unique. - - A 'root' component exists. - - All unique component references point to valid IDs. - 3. **Topology**: - - No circular references (including self-references). - - No orphaned components (all components must be reachable from 'root'). - 4. **Recursion Limits**: - - Global recursion depth limit (50). - - FunctionCall recursion depth limit (5). - 5. **Path Syntax**: - - Validates JSON Pointer syntax for data paths. - - Args: - a2ui_json: The JSON payload to validate. - a2ui_schema: The schema to validate against. - - Raises: - jsonschema.ValidationError: If the payload does not match the schema. - ValueError: If integrity, topology, or recursion checks fail. - """ - jsonschema.validate(instance=a2ui_json, schema=a2ui_schema) - - # Normalize to list for iteration - messages = a2ui_json if isinstance(a2ui_json, list) else [a2ui_json] - - for message in messages: - if not isinstance(message, dict): - continue - - # Check for SurfaceUpdate which has 'components' - if COMPONENTS in message: - ref_map = _extract_component_ref_fields(a2ui_schema) - _validate_component_integrity(message[COMPONENTS], ref_map) - _validate_topology(message[COMPONENTS], ref_map) - - _validate_recursion_and_paths(message) - - -def _validate_component_integrity( - components: List[Dict[str, Any]], - ref_fields_map: Dict[str, tuple[Set[str], Set[str]]], -) -> None: - """ - Validates that: - 1. All component IDs are unique. - 2. A 'root' component exists. - 3. All references (children, child, etc.) point to existing IDs. - """ - ids: Set[str] = set() - - # 1. Collect IDs and check for duplicates - for comp in components: - comp_id = comp.get(ID) - if comp_id is None: - continue - - if comp_id in ids: - raise ValueError(f"Duplicate component ID found: '{comp_id}'") - ids.add(comp_id) - - # 2. Check for root component - if ROOT not in ids: - raise ValueError( - f"Missing '{ROOT}' component: One component must have '{ID}' set to '{ROOT}'." - ) - - # 3. Check for dangling references using helper - for comp in components: - for ref_id, field_name in _get_component_references(comp, ref_fields_map): - if ref_id not in ids: - raise ValueError( - f"Component '{comp.get(ID)}' references missing ID '{ref_id}' in field" - f" '{field_name}'" - ) - - -def _validate_topology( - components: List[Dict[str, Any]], - ref_fields_map: Dict[str, tuple[Set[str], Set[str]]], -) -> None: - """ - Validates the topology of the component tree: - 1. No circular references (including self-references). - 2. No orphaned components (all components must be reachable from 'root'). - """ - adj_list: Dict[str, List[str]] = {} - all_ids: Set[str] = set() - - # Build Adjacency List - for comp in components: - comp_id = comp.get(ID) - if comp_id is None: - continue - - all_ids.add(comp_id) - if comp_id not in adj_list: - adj_list[comp_id] = [] - - for ref_id, field_name in _get_component_references(comp, ref_fields_map): - if ref_id == comp_id: - raise ValueError( - f"Self-reference detected: Component '{comp_id}' references itself in field" - f" '{field_name}'" - ) - adj_list[comp_id].append(ref_id) - - # Detect Cycles using DFS - visited: Set[str] = set() - recursion_stack: Set[str] = set() - - def dfs(node_id: str): - visited.add(node_id) - recursion_stack.add(node_id) - - for neighbor in adj_list.get(node_id, []): - if neighbor not in visited: - dfs(neighbor) - elif neighbor in recursion_stack: - raise ValueError( - f"Circular reference detected involving component '{neighbor}'" - ) - - recursion_stack.remove(node_id) - - if ROOT in all_ids: - dfs(ROOT) - - # Check for Orphans - orphans = all_ids - visited - if orphans: - sorted_orphans = sorted(list(orphans)) - raise ValueError( - f"Orphaned components detected (not reachable from '{ROOT}'): {sorted_orphans}" - ) - - -def _extract_component_ref_fields( - schema: Dict[str, Any], -) -> Dict[str, tuple[Set[str], Set[str]]]: - """ - Parses the JSON schema to identify which component properties reference other components. - Returns a map: { component_name: (set_of_single_ref_fields, set_of_list_ref_fields) } - """ - ref_map = {} - - root_defs = schema.get("$defs") or schema.get("definitions", {}) - - # Helper to check if a property schema looks like a ComponentId reference - def is_component_id_ref(prop_schema: Dict[str, Any]) -> bool: - ref = prop_schema.get("$ref", "") - if ref.endswith("ComponentId"): - return True - return False - - def is_child_list_ref(prop_schema: Dict[str, Any]) -> bool: - ref = prop_schema.get("$ref", "") - if ref.endswith("ChildList"): - return True - # Or array of ComponentIds - if prop_schema.get("type") == "array": - items = prop_schema.get("items", {}) - if is_component_id_ref(items): - return True - return False - - comps_schema = schema.get("properties", {}).get(COMPONENTS, {}) - items_schema = comps_schema.get("items", {}) - comp_props_schema = items_schema.get("properties", {}).get(COMPONENT_PROPERTIES, {}) - all_components = comp_props_schema.get("properties", {}) - - for comp_name, comp_schema in all_components.items(): - single_refs = set() - list_refs = set() - - props = comp_schema.get("properties", {}) - for prop_name, prop_schema in props.items(): - if is_component_id_ref(prop_schema): - single_refs.add(prop_name) - elif is_child_list_ref(prop_schema): - list_refs.add(prop_name) - - if single_refs or list_refs: - ref_map[comp_name] = (single_refs, list_refs) - - return ref_map - - -def _get_component_references( - component: Dict[str, Any], ref_fields_map: Dict[str, tuple[Set[str], Set[str]]] -) -> Iterator[Tuple[str, str]]: - """ - Helper to extract all referenced component IDs from a component. - Yields (referenced_id, field_name). - """ - comp_props_container = component.get(COMPONENT_PROPERTIES) - if not isinstance(comp_props_container, dict): - return - - for comp_type, props in comp_props_container.items(): - if not isinstance(props, dict): - continue - - single_refs, list_refs = ref_fields_map.get(comp_type, (set(), set())) - - for key, value in props.items(): - if key in single_refs: - if isinstance(value, str): - yield value, key - elif key in list_refs: - if isinstance(value, list): - for item in value: - if isinstance(item, str): - yield item, key - - -def _validate_recursion_and_paths(data: Any) -> None: - """ - Validates: - 1. Global recursion depth limit (50). - 2. FunctionCall recursion depth limit (5). - 3. Path syntax for DataBindings/DataModelUpdates. - """ - - def traverse(item: Any, global_depth: int, func_depth: int): - if global_depth > MAX_GLOBAL_DEPTH: - raise ValueError(f"Global recursion limit exceeded: Depth > {MAX_GLOBAL_DEPTH}") - - if isinstance(item, list): - for x in item: - traverse(x, global_depth + 1, func_depth) - return - - if isinstance(item, dict): - # Check for path - if PATH in item and isinstance(item[PATH], str): - path = item[PATH] - if not re.fullmatch(JSON_POINTER_PATTERN, path): - raise ValueError(f"Invalid JSON Pointer syntax: '{path}'") - - # Check for FunctionCall - is_func = CALL in item and ARGS in item - - if is_func: - if func_depth >= MAX_FUNC_CALL_DEPTH: - raise ValueError( - f"Recursion limit exceeded: {FUNCTION_CALL} depth > {MAX_FUNC_CALL_DEPTH}" - ) - - # Increment func_depth only for 'args', but global_depth matches traversal - for k, v in item.items(): - if k == ARGS: - traverse(v, global_depth + 1, func_depth + 1) - else: - traverse(v, global_depth + 1, func_depth) - else: - for v in item.values(): - traverse(v, global_depth + 1, func_depth) - - traverse(data, 0, 0) 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 6dbe5d7df..5e78bc30a 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 @@ -24,6 +24,7 @@ if TYPE_CHECKING: from .validator import A2uiValidator + from .payload_fixer import A2uiPayloadFixer @dataclass @@ -65,6 +66,12 @@ def validator(self) -> "A2uiValidator": return A2uiValidator(self) + @property + def payload_fixer(self) -> "A2uiPayloadFixer": + from .payload_fixer import A2uiPayloadFixer + + return A2uiPayloadFixer(self) + def with_pruned_components(self, allowed_components: List[str]) -> "A2uiCatalog": """Returns a new catalog with only allowed components. 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 9613a4b50..1252cbab3 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 @@ -26,12 +26,15 @@ BASIC_CATALOG_NAME = "basic" INLINE_CATALOG_NAME = "inline" +VERSION_0_8 = "0.8" +VERSION_0_9 = "0.9" + SPEC_VERSION_MAP = { - "0.8": { + VERSION_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": { + VERSION_0_9: { SERVER_TO_CLIENT_SCHEMA_KEY: "specification/v0_9/json/server_to_client.json", CATALOG_SCHEMA_KEY: "specification/v0_9/json/basic_catalog.json", COMMON_TYPES_SCHEMA_KEY: "specification/v0_9/json/common_types.json", diff --git a/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/payload_fixer.py b/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/payload_fixer.py new file mode 100644 index 000000000..cd02088a4 --- /dev/null +++ b/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/payload_fixer.py @@ -0,0 +1,84 @@ +# 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 logging +import re +from typing import Any, Dict, List, TYPE_CHECKING + +import jsonschema + +if TYPE_CHECKING: + from .catalog import A2uiCatalog + +logger = logging.getLogger(__name__) + + +class A2uiPayloadFixer: + + def __init__(self, catalog: "A2uiCatalog"): + self._catalog = catalog + + def _remove_trailing_commas(self, json_str: str) -> str: + """Attempts to remove trailing commas from a JSON string. + + Args: + json_str: The raw JSON string from the LLM. + + Returns: + A potentially fixed JSON string. + """ + # Fix trailing commas: identifying commas followed by optional whitespace and a closing bracket (]) or brace (}). + fixed_json = re.sub(r",(?=\s*[\]}])", "", json_str) + + if fixed_json != json_str: + logger.warning("Detected trailing commas in LLM output; applied autofix.") + + return fixed_json + + def _parse(self, payload: str) -> List[Dict[str, Any]]: + """Parses the payload and returns a list of A2UI JSON objects.""" + try: + a2ui_json = json.loads(payload) + if not isinstance(a2ui_json, list): + logger.info("Received a single JSON object, wrapping in a list for validation.") + a2ui_json = [a2ui_json] + return a2ui_json + except json.JSONDecodeError as e: + logger.error(f"Failed to parse JSON: {e}") + raise ValueError(f"Failed to parse JSON: {e}") + + def fix(self, payload: str) -> List[Dict[str, Any]]: + """Applies autofixes to a raw JSON string and returns the parsed payload. + + Args: + payload: The raw JSON string from the LLM. + + Returns: + A parsed and potentially fixed payload (list of dicts). + """ + try: + a2ui_json = self._parse(payload) + self._catalog.validator.validate(a2ui_json) + return a2ui_json + except ( + jsonschema.exceptions.ValidationError, + json.JSONDecodeError, + ValueError, + ) as e: + logger.warning(f"Initial A2UI payload validation failed: {e}") + updated_payload = self._remove_trailing_commas(payload) + a2ui_json = self._parse(updated_payload) + self._catalog.validator.validate(a2ui_json) + return a2ui_json 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 index a42b2052e..c0df592ff 100644 --- a/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/validator.py +++ b/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/validator.py @@ -13,7 +13,8 @@ # limitations under the License. import copy -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple +import re +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple, Union, Iterator from jsonschema import Draft202012Validator @@ -25,8 +26,26 @@ CATALOG_COMPONENTS_KEY, CATALOG_ID_KEY, CATALOG_STYLES_KEY, + VERSION_0_8, + VERSION_0_9, ) +# RFC 6901 compliant regex for JSON Pointer +JSON_POINTER_PATTERN = re.compile(r"^(?:\/(?:[^~\/]|~[01])*)*$") + +# Recursion Limits +MAX_GLOBAL_DEPTH = 50 +MAX_FUNC_CALL_DEPTH = 5 + +# Constants +COMPONENTS = "components" +ID = "id" +ROOT = "root" +PATH = "path" +FUNCTION_CALL = "functionCall" +CALL = "call" +ARGS = "args" + def _inject_additional_properties( schema: Dict[str, Any], @@ -80,7 +99,31 @@ def _wrap_main_schema(schema: Dict[str, Any]) -> Dict[str, Any]: class A2uiValidator: - """Validator for A2UI messages.""" + """Validates the A2UI JSON payload against the provided schema and checks for integrity. + + Checks performed: + 1. **JSON Schema Validation**: Ensures payload adheres to the A2UI schema. + 2. **Component Integrity**: + - All component IDs are unique. + - A 'root' component exists. + - All unique component references point to valid IDs. + 3. **Topology**: + - No circular references (including self-references). + - No orphaned components (all components must be reachable from 'root'). + 4. **Recursion Limits**: + - Global recursion depth limit (50). + - FunctionCall recursion depth limit (5). + 5. **Path Syntax**: + - Validates JSON Pointer syntax for data paths. + + Args: + a2ui_json: The JSON payload to validate. + a2ui_schema: The schema to validate against. + + Raises: + jsonschema.ValidationError: If the payload does not match the schema. + ValueError: If integrity, topology, or recursion checks fail. + """ def __init__(self, catalog: "A2uiCatalog"): self._catalog = catalog @@ -89,7 +132,7 @@ def __init__(self, catalog: "A2uiCatalog"): def _build_validator(self) -> Draft202012Validator: """Builds a validator for the A2UI schema.""" - if self._catalog.version == "0.8": + if self._catalog.version == VERSION_0_8: return self._build_0_8_validator() return self._build_0_9_validator() @@ -116,13 +159,48 @@ 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) + + from referencing import Registry, Resource + from referencing.jsonschema import DRAFT202012 + + # Even in v0.8, we may have references to common_types.json or other files. + 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) + + common_types_uri = get_sibling_uri(base_uri, "common_types.json") + + resources = [ + ( + common_types_uri, + Resource.from_contents( + self._catalog.common_types_schema, + default_specification=DRAFT202012, + ), + ), + ( + "common_types.json", + Resource.from_contents( + self._catalog.common_types_schema, + default_specification=DRAFT202012, + ), + ), + ] + + 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 _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 + from referencing.jsonschema import DRAFT202012 # v0.9 schemas (e.g. server_to_client.json) use relative references like # 'catalog.json#/$defs/anyComponent'. Since server_to_client.json has @@ -141,24 +219,42 @@ def get_sibling_uri(uri, filename): resources = [ ( common_types_uri, - Resource.from_contents(self._catalog.common_types_schema), + Resource.from_contents( + self._catalog.common_types_schema, + default_specification=DRAFT202012, + ), ), ( catalog_uri, - Resource.from_contents(self._catalog.catalog_schema), + Resource.from_contents( + self._catalog.catalog_schema, + default_specification=DRAFT202012, + ), ), # Fallbacks for robustness - ("catalog.json", Resource.from_contents(self._catalog.catalog_schema)), + ( + "catalog.json", + Resource.from_contents( + self._catalog.catalog_schema, + default_specification=DRAFT202012, + ), + ), ( "common_types.json", - Resource.from_contents(self._catalog.common_types_schema), + Resource.from_contents( + self._catalog.common_types_schema, + default_specification=DRAFT202012, + ), ), ] # 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), + Resource.from_contents( + self._catalog.catalog_schema, + default_specification=DRAFT202012, + ), )) registry = Registry().with_resources(resources) @@ -167,9 +263,12 @@ def get_sibling_uri(uri, filename): 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) + def validate(self, a2ui_json: Union[Dict[str, Any], List[Any]]) -> None: + """Validates an A2UI messages against the schema.""" + messages = a2ui_json if isinstance(a2ui_json, list) else [a2ui_json] + + # Basic schema validation + error = next(self._validator.iter_errors(messages), None) if error is not None: msg = f"Validation failed: {error.message}" if error.context: @@ -177,3 +276,376 @@ def validate(self, message: Dict[str, Any]) -> None: for sub_error in error.context: msg += f"\n - {sub_error.message}" raise ValueError(msg) + + root_id = _find_root_id(messages) + + for message in messages: + if not isinstance(message, dict): + continue + + components = None + if "surfaceUpdate" in message: # v0.8 + components = message["surfaceUpdate"].get(COMPONENTS) + elif "updateComponents" in message and isinstance( + message["updateComponents"], dict + ): # v0.9 + components = message["updateComponents"].get(COMPONENTS) + + if components: + ref_map = _extract_component_ref_fields(self._catalog) + _validate_component_integrity(root_id, components, ref_map) + _validate_topology(root_id, components, ref_map) + + _validate_recursion_and_paths(message) + + +def _find_root_id(messages: List[Dict[str, Any]]) -> str: + """ + Finds the root id from a list of A2UI messages. + - For v0.8, the root id is in the beginRendering message. + - For v0.9+, the root id is 'root'. + """ + for message in messages: + if not isinstance(message, dict): + continue + if "beginRendering" in message: + return message["beginRendering"].get(ROOT, ROOT) + return ROOT + + +def _validate_component_integrity( + root_id: str, + components: List[Dict[str, Any]], + ref_fields_map: Dict[str, tuple[Set[str], Set[str]]], +) -> None: + """ + Validates that: + 1. All component IDs are unique. + 2. A 'root' component exists. + 3. All references point to existing IDs. + """ + ids: Set[str] = set() + + # 1. Collect IDs and check for duplicates + for comp in components: + comp_id = comp.get(ID) + if comp_id is None: + continue + + if comp_id in ids: + raise ValueError(f"Duplicate component ID: {comp_id}") + ids.add(comp_id) + + # 2. Check for root component + if root_id not in ids: + raise ValueError(f"Missing root component: No component has id='{root_id}'") + + # 3. Check for dangling references using helper + for comp in components: + for ref_id, field_name in _get_component_references(comp, ref_fields_map): + if ref_id not in ids: + raise ValueError( + f"Component '{comp.get(ID)}' references non-existent component '{ref_id}'" + f" in field '{field_name}'" + ) + + +def _validate_topology( + root_id: str, + components: List[Dict[str, Any]], + ref_fields_map: Dict[str, tuple[Set[str], Set[str]]], +) -> None: + """ + Validates the topology of the component tree: + 1. No circular references (including self-references). + 2. No orphaned components (all components must be reachable from 'root'). + """ + adj_list: Dict[str, List[str]] = {} + all_ids: Set[str] = set() + + # Build Adjacency List + for comp in components: + comp_id = comp.get(ID) + if comp_id is None: + continue + + all_ids.add(comp_id) + if comp_id not in adj_list: + adj_list[comp_id] = [] + + for ref_id, field_name in _get_component_references(comp, ref_fields_map): + if ref_id == comp_id: + raise ValueError( + f"Self-reference detected: Component '{comp_id}' references itself in field" + f" '{field_name}'" + ) + adj_list[comp_id].append(ref_id) + + # Detect Cycles and Depth using DFS + visited: Set[str] = set() + recursion_stack: Set[str] = set() + + def dfs(node_id: str, depth: int): + if depth > MAX_GLOBAL_DEPTH: + raise ValueError( + f"Global recursion limit exceeded: logical depth > {MAX_GLOBAL_DEPTH}" + ) + + visited.add(node_id) + recursion_stack.add(node_id) + + for neighbor in adj_list.get(node_id, []): + if neighbor not in visited: + dfs(neighbor, depth + 1) + elif neighbor in recursion_stack: + raise ValueError( + f"Circular reference detected involving component '{neighbor}'" + ) + + recursion_stack.remove(node_id) + + if root_id in all_ids: + dfs(root_id, 0) + + # Check for Orphans + orphans = all_ids - visited + if orphans: + sorted_orphans = sorted(list(orphans)) + raise ValueError( + f"Component '{sorted_orphans[0]}' is not reachable from '{root_id}'" + ) + + +def _extract_component_ref_fields( + catalog: "A2uiCatalog", +) -> Dict[str, tuple[Set[str], Set[str]]]: + """ + Parses the catalog/schema to identify which component properties reference other components. + Returns a map: { component_name: (set_of_single_ref_fields, set_of_list_ref_fields) } + """ + ref_map = {} + + all_components = {} + # Version aware extraction + if catalog.version == VERSION_0_8: + # Search for components in s2c schema properties + try: + # Try nested path: surfaceUpdate -> components -> items -> properties -> component -> properties + s2c = catalog.s2c_schema or {} + props = s2c.get("properties", {}) + + # Might be in surfaceUpdate or beginRendering component definitions + if "surfaceUpdate" in props: + su = props["surfaceUpdate"].get("properties", {}) + if "components" in su: + items = su["components"].get("items", {}) + if "properties" in items: + comp_wrapper = items["properties"].get("component", {}) + all_components = comp_wrapper.get("properties", {}) + except Exception: + logging.warning("Failed to extract component ref fields from v0.8 schema") + + # Also check catalog schema if available + if not all_components and catalog.catalog_schema: + all_components = catalog.catalog_schema.get(COMPONENTS, {}) + else: # v0.9+ + # In v0.9, components are defined in the catalog itself + all_components = catalog.catalog_schema.get(COMPONENTS, {}) + + # Helper to check if a property schema looks like a ComponentId reference + def is_component_id_ref(prop_schema: Dict[str, Any]) -> bool: + if not isinstance(prop_schema, dict): + return False + ref = prop_schema.get("$ref", "") + if isinstance(ref, str) and ( + ref.endswith("ComponentId") or ref.endswith("child") or "/child" in ref + ): + return True + + # Inline check + if ( + prop_schema.get("type") == "string" + and prop_schema.get("title") == "ComponentId" + ): + return True + + # Check oneOf/anyOf for refs + for key in ["oneOf", "anyOf", "allOf"]: + if key in prop_schema: + for sub in prop_schema[key]: + if is_component_id_ref(sub): + return True + return False + + def is_child_list_ref(prop_schema: Dict[str, Any]) -> bool: + if not isinstance(prop_schema, dict): + return False + ref = prop_schema.get("$ref", "") + if isinstance(ref, str) and ( + ref.endswith("ChildList") or ref.endswith("children") or "/children" in ref + ): + return True + + # Inline check + if prop_schema.get("type") == "object": + props = prop_schema.get("properties", {}) + if "explicitList" in props or "template" in props or "componentId" in props: + return True + + # Or array of ComponentIds + if prop_schema.get("type") == "array": + items = prop_schema.get("items", {}) + if is_component_id_ref(items): + return True + + # Check oneOf/anyOf for refs + for key in ["oneOf", "anyOf", "allOf"]: + if key in prop_schema: + for sub in prop_schema[key]: + if is_child_list_ref(sub): + return True + return False + + for comp_name, comp_schema in all_components.items(): + single_refs = set() + list_refs = set() + + def extract_from_props(cs: Dict[str, Any]): + if not isinstance(cs, dict): + return + props = cs.get("properties", {}) + for prop_name, prop_schema in props.items(): + if is_component_id_ref(prop_schema) or prop_name in [ + "child", + "contentChild", + "entryPointChild", + ]: + single_refs.add(prop_name) + elif is_child_list_ref(prop_schema) or prop_name == "children": + list_refs.add(prop_name) + + # Recurse into allOf/oneOf for properties + for key in ["allOf", "oneOf", "anyOf"]: + if key in cs: + for sub in cs[key]: + extract_from_props(sub) + + extract_from_props(comp_schema) + + if single_refs or list_refs: + ref_map[comp_name] = (single_refs, list_refs) + + return ref_map + + +def _get_component_references( + component: Dict[str, Any], ref_fields_map: Dict[str, tuple[Set[str], Set[str]]] +) -> Iterator[Tuple[str, str]]: + """ + Helper to extract all referenced component IDs from a component. + Yields (referenced_id, field_name). + """ + # Support both v0.8 and v0.9+ + comp_type = None + props = {} + + if "component" in component: + comp_val = component.get("component") + if isinstance(comp_val, str): + # v0.9 flattened + yield from _get_refs_recursively(comp_val, component, ref_fields_map) + elif isinstance(comp_val, dict): + # v0.8 structured + for c_type, c_props in comp_val.items(): + # Recurse into the properties container + if isinstance(c_props, dict): + yield from _get_refs_recursively(c_type, c_props, ref_fields_map) + + +def _get_refs_recursively( + comp_type: str, + props: Dict[str, Any], + ref_fields_map: Dict[str, tuple[Set[str], Set[str]]], +) -> Iterator[Tuple[str, str]]: + if not comp_type or not isinstance(props, dict): + return + + single_refs, list_refs = ref_fields_map.get(comp_type, (set(), set())) + + for key, value in props.items(): + if key in single_refs: + if isinstance(value, str): + yield value, key + elif isinstance(value, dict) and "componentId" in value: # ChildList template + yield value["componentId"], f"{key}.componentId" + elif key in list_refs: + if isinstance(value, list): + for item in value: + if isinstance(item, str): + yield item, key + elif isinstance(value, dict): + if "explicitList" in value: + for item in value["explicitList"]: + if isinstance(item, str): + yield item, f"{key}.explicitList" + elif "template" in value: + template = value["template"] + if isinstance(template, dict) and "componentId" in template: + yield template["componentId"], f"{key}.template.componentId" + elif "componentId" in value: + yield value["componentId"], f"{key}.componentId" + + # Special handling for 'tabs' or other nested arrays + if isinstance(value, list) and key not in list_refs: + for idx, item in enumerate(value): + if isinstance(item, dict): + # Check for common patterns like {title, child} + child_id = item.get("child") + if child_id and isinstance(child_id, str): + yield child_id, f"{key}[{idx}].child" + + +def _validate_recursion_and_paths(data: Any) -> None: + """ + Validates: + 1. Global recursion depth limit (50). + 2. FunctionCall recursion depth limit (5). + 3. Path syntax for DataBindings/DataModelUpdates. + """ + + def traverse(item: Any, global_depth: int, func_depth: int): + if global_depth > MAX_GLOBAL_DEPTH: + raise ValueError(f"Global recursion limit exceeded: Depth > {MAX_GLOBAL_DEPTH}") + + if isinstance(item, list): + for x in item: + traverse(x, global_depth + 1, func_depth) + return + + if isinstance(item, dict): + # Check for path + if PATH in item and isinstance(item[PATH], str): + path = item[PATH] + if not re.fullmatch(JSON_POINTER_PATTERN, path): + raise ValueError(f"Invalid JSON Pointer syntax: '{path}'") + + # Check for FunctionCall + is_func = CALL in item and ARGS in item + + if is_func: + if func_depth >= MAX_FUNC_CALL_DEPTH: + raise ValueError( + f"Recursion limit exceeded: {FUNCTION_CALL} depth > {MAX_FUNC_CALL_DEPTH}" + ) + + # Increment func_depth only for 'args', but global_depth matches traversal + for k, v in item.items(): + if k == ARGS: + traverse(v, global_depth + 1, func_depth + 1) + else: + traverse(v, global_depth + 1, func_depth) + else: + for v in item.values(): + traverse(v, global_depth + 1, func_depth) + + traverse(data, 0, 0) diff --git a/a2a_agents/python/a2ui_agent/tests/extension/test_send_a2ui_to_client_toolset.py b/a2a_agents/python/a2ui_agent/tests/extension/test_send_a2ui_to_client_toolset.py index 50afd8085..dcd7d089e 100644 --- a/a2a_agents/python/a2ui_agent/tests/extension/test_send_a2ui_to_client_toolset.py +++ b/a2a_agents/python/a2ui_agent/tests/extension/test_send_a2ui_to_client_toolset.py @@ -22,45 +22,49 @@ from a2ui.extension.send_a2ui_to_client_toolset import convert_send_a2ui_to_client_genai_part_to_a2a_part from a2ui.extension.send_a2ui_to_client_toolset import SendA2uiToClientToolset +from a2ui.inference.schema.catalog import A2uiCatalog from google.adk.agents.readonly_context import ReadonlyContext from google.adk.tools.tool_context import ToolContext from google.genai import types as genai_types -# Basic A2UI Schema for testing -TEST_A2UI_SCHEMA = { - "type": "object", - "properties": {"type": {"const": "Text"}, "text": {"type": "string"}}, - "required": ["type", "text"], -} - # region SendA2uiToClientToolset Tests """Tests for the SendA2uiToClientToolset class.""" @pytest.mark.asyncio async def test_toolset_init_bool(): - toolset = SendA2uiToClientToolset(a2ui_enabled=True, a2ui_schema=TEST_A2UI_SCHEMA) + catalog_mock = MagicMock(spec=A2uiCatalog) + toolset = SendA2uiToClientToolset( + a2ui_enabled=True, a2ui_catalog=catalog_mock, a2ui_examples="examples" + ) ctx = MagicMock(spec=ReadonlyContext) assert await toolset._resolve_a2ui_enabled(ctx) == True # Access the tool to check schema resolution tool = toolset._ui_tools[0] - assert await tool._resolve_a2ui_schema(ctx) == TEST_A2UI_SCHEMA + assert await tool._resolve_a2ui_catalog(ctx) == catalog_mock @pytest.mark.asyncio async def test_toolset_init_callable(): enabled_mock = MagicMock(return_value=True) - schema_mock = MagicMock(return_value=TEST_A2UI_SCHEMA) - toolset = SendA2uiToClientToolset(a2ui_enabled=enabled_mock, a2ui_schema=schema_mock) + catalog_mock = MagicMock(spec=A2uiCatalog) + examples_mock = MagicMock(return_value="examples") + toolset = SendA2uiToClientToolset( + a2ui_enabled=enabled_mock, + a2ui_catalog=catalog_mock, + a2ui_examples=examples_mock, + ) ctx = MagicMock(spec=ReadonlyContext) assert await toolset._resolve_a2ui_enabled(ctx) == True # Access the tool to check schema resolution tool = toolset._ui_tools[0] - assert await tool._resolve_a2ui_schema(ctx) == TEST_A2UI_SCHEMA + assert await tool._resolve_a2ui_catalog(ctx) == catalog_mock + assert await tool._resolve_a2ui_examples(ctx) == "examples" enabled_mock.assert_called_once_with(ctx) - schema_mock.assert_called_once_with(ctx) + catalog_mock.assert_not_called() # It's an object, not a callable in this test + examples_mock.assert_called_once_with(ctx) @pytest.mark.asyncio @@ -68,23 +72,33 @@ async def test_toolset_init_async_callable(): async def async_enabled(_ctx): return True - async def async_schema(_ctx): - return TEST_A2UI_SCHEMA + catalog_mock = MagicMock(spec=A2uiCatalog) + + async def async_catalog(_ctx): + return catalog_mock + + async def async_examples(_ctx): + return "examples" toolset = SendA2uiToClientToolset( - a2ui_enabled=async_enabled, a2ui_schema=async_schema + a2ui_enabled=async_enabled, + a2ui_catalog=async_catalog, + a2ui_examples=async_examples, ) ctx = MagicMock(spec=ReadonlyContext) assert await toolset._resolve_a2ui_enabled(ctx) == True # Access the tool to check schema resolution tool = toolset._ui_tools[0] - assert await tool._resolve_a2ui_schema(ctx) == TEST_A2UI_SCHEMA + assert await tool._resolve_a2ui_catalog(ctx) == catalog_mock + assert await tool._resolve_a2ui_examples(ctx) == "examples" @pytest.mark.asyncio async def test_toolset_get_tools_enabled(): - toolset = SendA2uiToClientToolset(a2ui_enabled=True, a2ui_schema=TEST_A2UI_SCHEMA) + toolset = SendA2uiToClientToolset( + a2ui_enabled=True, a2ui_catalog=MagicMock(spec=A2uiCatalog), a2ui_examples="" + ) tools = await toolset.get_tools(MagicMock(spec=ReadonlyContext)) assert len(tools) == 1 assert isinstance(tools[0], SendA2uiToClientToolset._SendA2uiJsonToClientTool) @@ -92,7 +106,11 @@ async def test_toolset_get_tools_enabled(): @pytest.mark.asyncio async def test_toolset_get_tools_disabled(): - toolset = SendA2uiToClientToolset(a2ui_enabled=False, a2ui_schema=TEST_A2UI_SCHEMA) + toolset = SendA2uiToClientToolset( + a2ui_enabled=False, + a2ui_catalog=MagicMock(spec=A2uiCatalog), + a2ui_examples="", + ) tools = await toolset.get_tools(MagicMock(spec=ReadonlyContext)) assert len(tools) == 0 @@ -104,13 +122,16 @@ async def test_toolset_get_tools_disabled(): def test_send_tool_init(): - tool = SendA2uiToClientToolset._SendA2uiJsonToClientTool(TEST_A2UI_SCHEMA) + catalog_mock = MagicMock(spec=A2uiCatalog) + tool = SendA2uiToClientToolset._SendA2uiJsonToClientTool(catalog_mock, "examples") assert tool.name == SendA2uiToClientToolset._SendA2uiJsonToClientTool.TOOL_NAME - assert tool._a2ui_schema == TEST_A2UI_SCHEMA + assert tool._a2ui_catalog == catalog_mock + assert tool._a2ui_examples == "examples" def test_send_tool_get_declaration(): - tool = SendA2uiToClientToolset._SendA2uiJsonToClientTool(TEST_A2UI_SCHEMA) + catalog_mock = MagicMock(spec=A2uiCatalog) + tool = SendA2uiToClientToolset._SendA2uiJsonToClientTool(catalog_mock, "examples") declaration = tool._get_declaration() assert declaration is not None assert declaration.name == SendA2uiToClientToolset._SendA2uiJsonToClientTool.TOOL_NAME @@ -125,24 +146,28 @@ def test_send_tool_get_declaration(): @pytest.mark.asyncio -async def test_send_tool_get_a2ui_schema(): - schema_mock = MagicMock(return_value=TEST_A2UI_SCHEMA) - tool = SendA2uiToClientToolset._SendA2uiJsonToClientTool(schema_mock) - schema = await tool.get_a2ui_schema(MagicMock(spec=ReadonlyContext)) - assert schema == {"type": "array", "items": TEST_A2UI_SCHEMA} +async def test_send_tool_resolve_catalog(): + catalog_mock = MagicMock(spec=A2uiCatalog) + tool = SendA2uiToClientToolset._SendA2uiJsonToClientTool(catalog_mock, "examples") + catalog = await tool._resolve_a2ui_catalog(MagicMock(spec=ReadonlyContext)) + assert catalog == catalog_mock @pytest.mark.asyncio -async def test_send_tool_get_a2ui_schema_empty(): - schema_mock = MagicMock(return_value=None) - tool = SendA2uiToClientToolset._SendA2uiJsonToClientTool(schema_mock) - with pytest.raises(ValueError): - await tool.get_a2ui_schema(MagicMock(spec=ReadonlyContext)) +async def test_send_tool_resolve_examples(): + tool = SendA2uiToClientToolset._SendA2uiJsonToClientTool( + MagicMock(spec=A2uiCatalog), "examples" + ) + examples = await tool._resolve_a2ui_examples(MagicMock(spec=ReadonlyContext)) + assert examples == "examples" @pytest.mark.asyncio async def test_send_tool_process_llm_request(): - tool = SendA2uiToClientToolset._SendA2uiJsonToClientTool(TEST_A2UI_SCHEMA) + catalog_mock = MagicMock(spec=A2uiCatalog) + catalog_mock.render_as_llm_instructions.return_value = "rendered_catalog" + tool = SendA2uiToClientToolset._SendA2uiJsonToClientTool(catalog_mock, "examples") + tool_context_mock = MagicMock(spec=ToolContext) tool_context_mock.state = {} llm_request_mock = MagicMock() @@ -154,20 +179,21 @@ async def test_send_tool_process_llm_request(): llm_request_mock.append_instructions.assert_called_once() args, _ = llm_request_mock.append_instructions.call_args - instruction = args[0][0] - assert "---BEGIN A2UI JSON SCHEMA---" in instruction - assert json.dumps({"type": "array", "items": TEST_A2UI_SCHEMA}) in instruction - assert "---END A2UI JSON SCHEMA---" in instruction + instructions = args[0] + assert "rendered_catalog" in instructions + assert "examples" in instructions @pytest.mark.asyncio async def test_send_tool_run_async_valid(): - tool = SendA2uiToClientToolset._SendA2uiJsonToClientTool(TEST_A2UI_SCHEMA) + catalog_mock = MagicMock(spec=A2uiCatalog) + tool = SendA2uiToClientToolset._SendA2uiJsonToClientTool(catalog_mock, "examples") tool_context_mock = MagicMock(spec=ToolContext) tool_context_mock.state = {} tool_context_mock.actions = MagicMock(skip_summarization=False) valid_a2ui = [{"type": "Text", "text": "Hello"}] + catalog_mock.payload_fixer.fix.return_value = valid_a2ui args = { SendA2uiToClientToolset._SendA2uiJsonToClientTool.A2UI_JSON_ARG_NAME: json.dumps( valid_a2ui @@ -185,12 +211,14 @@ async def test_send_tool_run_async_valid(): @pytest.mark.asyncio async def test_send_tool_run_async_valid_list(): - tool = SendA2uiToClientToolset._SendA2uiJsonToClientTool(TEST_A2UI_SCHEMA) + catalog_mock = MagicMock(spec=A2uiCatalog) + tool = SendA2uiToClientToolset._SendA2uiJsonToClientTool(catalog_mock, "examples") tool_context_mock = MagicMock(spec=ToolContext) tool_context_mock.state = {} tool_context_mock.actions = MagicMock(skip_summarization=False) valid_a2ui = [{"type": "Text", "text": "Hello"}] + catalog_mock.payload_fixer.fix.return_value = valid_a2ui args = { SendA2uiToClientToolset._SendA2uiJsonToClientTool.A2UI_JSON_ARG_NAME: json.dumps( valid_a2ui @@ -208,7 +236,9 @@ async def test_send_tool_run_async_valid_list(): @pytest.mark.asyncio async def test_send_tool_run_async_missing_arg(): - tool = SendA2uiToClientToolset._SendA2uiJsonToClientTool(TEST_A2UI_SCHEMA) + tool = SendA2uiToClientToolset._SendA2uiJsonToClientTool( + MagicMock(spec=A2uiCatalog), "examples" + ) result = await tool.run_async(args={}, tool_context=MagicMock()) assert "error" in result assert ( @@ -219,7 +249,9 @@ async def test_send_tool_run_async_missing_arg(): @pytest.mark.asyncio async def test_send_tool_run_async_invalid_json(): - tool = SendA2uiToClientToolset._SendA2uiJsonToClientTool(TEST_A2UI_SCHEMA) + catalog_mock = MagicMock(spec=A2uiCatalog) + catalog_mock.payload_fixer.fix.side_effect = Exception("Failed to parse JSON") + tool = SendA2uiToClientToolset._SendA2uiJsonToClientTool(catalog_mock, "examples") args = { SendA2uiToClientToolset._SendA2uiJsonToClientTool.A2UI_JSON_ARG_NAME: "{invalid" } @@ -230,7 +262,11 @@ async def test_send_tool_run_async_invalid_json(): @pytest.mark.asyncio async def test_send_tool_run_async_schema_validation_fail(): - tool = SendA2uiToClientToolset._SendA2uiJsonToClientTool(TEST_A2UI_SCHEMA) + catalog_mock = MagicMock(spec=A2uiCatalog) + catalog_mock.payload_fixer.fix.side_effect = Exception( + "'text' is a required property" + ) + tool = SendA2uiToClientToolset._SendA2uiJsonToClientTool(catalog_mock, "examples") invalid_a2ui = [{"type": "Text"}] # Missing 'text' args = { SendA2uiToClientToolset._SendA2uiJsonToClientTool.A2UI_JSON_ARG_NAME: json.dumps( @@ -243,37 +279,6 @@ async def test_send_tool_run_async_schema_validation_fail(): assert "'text' is a required property" in result["error"] -@pytest.mark.asyncio -async def test_send_tool_run_async_handles_trailing_comma(caplog): - """Tests that the tool's run_async can handle and fix a trailing comma in the JSON.""" - tool = SendA2uiToClientToolset._SendA2uiJsonToClientTool(TEST_A2UI_SCHEMA) - tool_context_mock = MagicMock(spec=ToolContext) - tool_context_mock.state = {} - tool_context_mock.actions = MagicMock(skip_summarization=False) - - # Malformed JSON with a trailing comma in the list - malformed_a2ui_str = '[{"type": "Text", "text": "Hello"},]' - - args = { - SendA2uiToClientToolset._SendA2uiJsonToClientTool.A2UI_JSON_ARG_NAME: ( - malformed_a2ui_str - ) - } - - result = await tool.run_async(args=args, tool_context=tool_context_mock) - - # Assert that the fix was successful and the result is correct - expected_a2ui = [{"type": "Text", "text": "Hello"}] - assert result == { - SendA2uiToClientToolset._SendA2uiJsonToClientTool.VALIDATED_A2UI_JSON_KEY: ( - expected_a2ui - ) - } - - # Assert that the warning was logged - assert "Detected trailing commas in LLM output; applied autofix." in caplog.text - - # endregion # region send_a2ui_to_client_part_converter Tests diff --git a/a2a_agents/python/a2ui_agent/tests/inference/test_payload_fixer.py b/a2a_agents/python/a2ui_agent/tests/inference/test_payload_fixer.py new file mode 100644 index 000000000..80bbf9dda --- /dev/null +++ b/a2a_agents/python/a2ui_agent/tests/inference/test_payload_fixer.py @@ -0,0 +1,97 @@ +# 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 pytest +import jsonschema +from unittest.mock import MagicMock +from a2ui.inference.schema.catalog import A2uiCatalog +from a2ui.inference.schema.payload_fixer import A2uiPayloadFixer + + +def test_remove_trailing_commas(caplog): + """Tests that the fixer can handle and fix trailing commas in JSON lists and objects.""" + catalog_mock = MagicMock(spec=A2uiCatalog) + fixer = A2uiPayloadFixer(catalog_mock) + + # Malformed JSON with a trailing comma in the list + malformed_json_list = '[{"type": "Text", "text": "Hello"},]' + fixed_json_list = fixer._remove_trailing_commas(malformed_json_list) + assert fixed_json_list == '[{"type": "Text", "text": "Hello"}]' + + # Malformed JSON with a trailing comma in the object + malformed_json_obj = '{"type": "Text", "text": "Hello",}' + fixed_json_obj = fixer._remove_trailing_commas(malformed_json_obj) + assert fixed_json_obj == '{"type": "Text", "text": "Hello"}' + + # Assert that the warning was logged + assert "Detected trailing commas in LLM output; applied autofix." in caplog.text + + +def test_remove_trailing_commas_no_change(): + """Tests that the fixer does not modify valid JSON.""" + catalog_mock = MagicMock(spec=A2uiCatalog) + fixer = A2uiPayloadFixer(catalog_mock) + + valid_json = '[{"type": "Text", "text": "Hello"}]' + fixed_json = fixer._remove_trailing_commas(valid_json) + + assert fixed_json == valid_json + + +def test_parse_payload_wrapping(): + """Tests that _parse_payload auto-wraps single objects in a list.""" + catalog_mock = MagicMock(spec=A2uiCatalog) + fixer = A2uiPayloadFixer(catalog_mock) + + obj_json = '{"type": "Text", "text": "Hello"}' + parsed = fixer._parse(obj_json) + assert isinstance(parsed, list) + assert len(parsed) == 1 + assert parsed[0]["type"] == "Text" + + +def test_fix_payload_success_first_time(): + """Tests that fix_payload returns the payload if it is valid immediately.""" + catalog_mock = MagicMock(spec=A2uiCatalog) + fixer = A2uiPayloadFixer(catalog_mock) + + valid_json = '[{"type": "Text", "text": "Hello"}]' + result = fixer.fix(valid_json) + + assert result == [{"type": "Text", "text": "Hello"}] + catalog_mock.validator.validate.assert_called_once() + + +def test_fix_payload_success_after_fix(caplog): + """Tests that fix_payload applies fix if initial validation fails.""" + catalog_mock = MagicMock(spec=A2uiCatalog) + + # Mock validate to fail first time, then succeed + def side_effect(instance): + # This is a bit simplified, but demonstrates the flow + if len(instance) == 0: # Should not happen with our test data but for example + raise jsonschema.exceptions.ValidationError("Empty list") + # In reality, initial parse will fail for trailing comma, so we need to mock that too. + pass + + catalog_mock.validator.validate.side_effect = side_effect + fixer = A2uiPayloadFixer(catalog_mock) + + malformed_json = '[{"type": "Text", "text": "Hello"},]' + result = fixer.fix(malformed_json) + + assert result == [{"type": "Text", "text": "Hello"}] + assert "Initial A2UI payload validation failed" in caplog.text + assert "Detected trailing commas in LLM output; applied autofix." in caplog.text diff --git a/a2a_agents/python/a2ui_agent/tests/inference/test_validator.py b/a2a_agents/python/a2ui_agent/tests/inference/test_validator.py index 5c55ec89b..35c4f4efb 100644 --- a/a2a_agents/python/a2ui_agent/tests/inference/test_validator.py +++ b/a2a_agents/python/a2ui_agent/tests/inference/test_validator.py @@ -17,6 +17,7 @@ import pytest from unittest.mock import MagicMock from a2ui.inference.schema.manager import A2uiSchemaManager, A2uiCatalog, CustomCatalogConfig +from a2ui.inference.schema.constants import VERSION_0_8, VERSION_0_9 class TestValidator: @@ -30,6 +31,7 @@ def catalog_0_9(self): "oneOf": [ {"$ref": "#/$defs/CreateSurfaceMessage"}, {"$ref": "#/$defs/UpdateComponentsMessage"}, + {"$ref": "#/$defs/UpdateDataModelMessage"}, ], "$defs": { "CreateSurfaceMessage": { @@ -105,6 +107,85 @@ def catalog_0_9(self): "catalogId": "https://a2ui.dev/specification/v0_9/basic_catalog.json", "components": { "Text": { + "type": "object", + "allOf": [ + {"$ref": "common_types.json#/$defs/ComponentCommon"}, + {"$ref": "#/$defs/CatalogComponentCommon"}, + ], + "properties": { + "component": {"const": "Text"}, + "text": {"$ref": "common_types.json#/$defs/DynamicString"}, + }, + "required": ["component", "text"], + "unevaluatedProperties": False, + }, + "Image": { + "type": "object", + "allOf": [ + {"$ref": "common_types.json#/$defs/ComponentCommon"}, + {"$ref": "#/$defs/CatalogComponentCommon"}, + ], + "properties": { + "component": {"const": "Image"}, + "url": {"type": "string"}, + }, + "required": ["component", "url"], + "unevaluatedProperties": False, + }, + "Icon": { + "type": "object", + "allOf": [ + {"$ref": "common_types.json#/$defs/ComponentCommon"}, + {"$ref": "#/$defs/CatalogComponentCommon"}, + ], + "properties": { + "component": {"const": "Icon"}, + "name": {"type": "string"}, + }, + "required": ["component", "name"], + "unevaluatedProperties": False, + }, + "Column": { + "type": "object", + "allOf": [ + {"$ref": "common_types.json#/$defs/ComponentCommon"}, + {"$ref": "#/$defs/CatalogComponentCommon"}, + ], + "properties": { + "component": {"const": "Column"}, + "children": {"$ref": "common_types.json#/$defs/ChildList"}, + }, + "required": ["component", "children"], + "unevaluatedProperties": False, + }, + "Card": { + "type": "object", + "allOf": [ + {"$ref": "common_types.json#/$defs/ComponentCommon"}, + {"$ref": "#/$defs/CatalogComponentCommon"}, + ], + "properties": { + "component": {"const": "Card"}, + "child": {"$ref": "common_types.json#/$defs/ComponentId"}, + }, + "required": ["component", "child"], + "unevaluatedProperties": False, + }, + "Button": { + "type": "object", + "allOf": [ + {"$ref": "common_types.json#/$defs/ComponentCommon"}, + {"$ref": "#/$defs/CatalogComponentCommon"}, + ], + "properties": { + "component": {"const": "Button"}, + "text": {"type": "string"}, + "action": {"$ref": "common_types.json#/$defs/Action"}, + }, + "required": ["component", "text", "action"], + "unevaluatedProperties": False, + }, + "List": { "type": "object", "allOf": [ {"$ref": "common_types.json#/$defs/ComponentCommon"}, @@ -112,29 +193,19 @@ def catalog_0_9(self): { "type": "object", "properties": { - "component": {"const": "Text"}, - "text": {"$ref": "common_types.json#/$defs/DynamicString"}, - "variant": { + "component": {"const": "List"}, + "children": {"$ref": "common_types.json#/$defs/ChildList"}, + "direction": { "type": "string", - "enum": [ - "h1", - "h2", - "h3", - "h4", - "h5", - "caption", - "body", - ], + "enum": ["vertical", "horizontal"], }, }, - "required": ["component", "text"], + "required": ["component", "children"], }, ], + "unevaluatedProperties": False, }, - "Image": {}, - "Icon": {}, }, - "theme": {"primaryColor": {"type": "string"}, "iconUrl": {"type": "string"}}, "$defs": { "CatalogComponentCommon": { "type": "object", @@ -145,6 +216,10 @@ def catalog_0_9(self): {"$ref": "#/components/Text"}, {"$ref": "#/components/Image"}, {"$ref": "#/components/Icon"}, + {"$ref": "#/components/Column"}, + {"$ref": "#/components/Card"}, + {"$ref": "#/components/Button"}, + {"$ref": "#/components/List"}, ], "discriminator": {"propertyName": "component"}, }, @@ -166,6 +241,7 @@ def catalog_0_9(self): } }, }, + "Action": {"type": "object", "additionalProperties": True}, "ComponentCommon": { "type": "object", "properties": {"id": {"$ref": "#/$defs/ComponentId"}}, @@ -187,8 +263,16 @@ def catalog_0_9(self): }, "ChildList": { "oneOf": [ - {"type": "array", "items": {"type": "string"}}, - {"$ref": "#/$defs/DataBinding"}, + {"type": "array", "items": {"$ref": "#/$defs/ComponentId"}}, + { + "type": "object", + "properties": { + "componentId": {"$ref": "#/$defs/ComponentId"}, + "path": {"type": "string"}, + }, + "required": ["componentId", "path"], + "additionalProperties": False, + }, ] }, }, @@ -214,6 +298,7 @@ def catalog_0_8(self): "additionalProperties": False, "properties": { "surfaceId": {"type": "string"}, + "root": {"type": "string"}, "styles": { "type": "object", "description": "Styling information for the UI.", @@ -248,15 +333,71 @@ def catalog_0_8(self): }, }, }, + "required": ["surfaceId", "components"], + }, + "dataModelUpdate": { + "type": "object", + "properties": { + "surfaceId": {"type": "string"}, + "contents": {"type": "object", "additionalProperties": True}, + }, }, - "required": ["surfaceId", "components"], }, + "additionalProperties": False, } catalog_schema = { "catalogId": ( "https://a2ui.org/specification/v0_8/json/standard_catalog_definition.json" ), - "components": {"Text": {"type": "object"}, "Button": {"type": "object"}}, + "components": { + "Column": { + "type": "object", + "additionalProperties": True, + "properties": { + "children": {"type": "array", "items": {"type": "string"}} + }, + }, + "Card": { + "type": "object", + "additionalProperties": True, + "properties": {"child": {"type": "string"}}, + }, + "Button": { + "type": "object", + "additionalProperties": True, + "properties": { + "label": {"type": "string"}, + "action": { + "type": "object", + "properties": { + "functionCall": { + "type": "object", + "properties": { + "call": {"type": "string"}, + "args": {"type": "object"}, + }, + } + }, + }, + }, + }, + "Text": { + "type": "object", + "additionalProperties": True, + "properties": { + "text": { + "anyOf": [ + {"type": "string"}, + {"type": "object", "additionalProperties": True}, + ] + } + }, + }, + "List": { + "type": "object", + "additionalProperties": True, + }, + }, "styles": {"font": {"type": "string"}, "primaryColor": {"type": "string"}}, } return A2uiCatalog( @@ -267,6 +408,13 @@ def catalog_0_8(self): common_types_schema=None, ) + @pytest.fixture(params=["0.8", "0.9"]) + def test_catalog(self, request, catalog_0_8, catalog_0_9): + """Parameterized fixture to run tests on both v0.8 and v0.9 catalogs.""" + if request.param == "0.8": + return catalog_0_8 + return catalog_0_9 + 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. @@ -413,9 +561,15 @@ def test_custom_catalog_0_8(self, catalog_0_8): "surfaceUpdate": { "surfaceId": "id1", "components": [ + { + "id": "root", + "component": { + "Canvas": {"children": {"explicitList": ["c1", "c2"]}} + }, + }, { "id": "c1", - "component": {"Canvas": {"children": {"explicitList": ["item1"]}}}, + "component": {"Canvas": {"children": {"explicitList": []}}}, }, { "id": "c2", @@ -508,7 +662,8 @@ def test_custom_catalog_0_9(self, catalog_0_9): "updateComponents": { "surfaceId": "s1", "components": [ - {"id": "c1", "component": "Canvas", "children": ["child1"]}, + {"id": "root", "component": "Canvas", "children": ["c1", "c2"]}, + {"id": "c1", "component": "Canvas", "children": []}, { "id": "c2", "component": "Chart", @@ -536,3 +691,318 @@ def test_bundle_0_8(self, catalog_0_8): assert component_node["additionalProperties"] is False assert "Text" in component_node["properties"] assert "Button" in component_node["properties"] + + def make_payload(self, catalog, components=None, data_model=None): + """Helper to create a version-appropriate message payload.""" + payload = None + if components: + processed_components = list(components) + + processed = [] + for comp in processed_components: + if catalog.version == VERSION_0_8: + if isinstance(comp.get("component"), str): + c = copy.deepcopy(comp) + c_id = c.pop("id") + c_type = c.pop("component") + processed.append({"id": c_id, "component": {c_type: c}}) + else: + processed.append(comp) + else: + if isinstance(comp.get("component"), dict): + c = copy.deepcopy(comp) + c_id = c.pop("id") + c_comp_dict = c.pop("component") + c_type = list(c_comp_dict.keys())[0] + c_props = c_comp_dict[c_type] + new_comp = {"id": c_id, "component": c_type} + new_comp.update(c_props) + processed.append(new_comp) + else: + processed.append(comp) + + if catalog.version == VERSION_0_8: + payload = { + "surfaceUpdate": {"surfaceId": "test-surface", "components": processed} + } + else: + payload = { + "version": "v0.9", + "updateComponents": {"surfaceId": "test-surface", "components": processed}, + } + + elif data_model: + if catalog.version == VERSION_0_8: + payload = { + "dataModelUpdate": {"surfaceId": "test-surface", "contents": data_model} + } + else: + payload = { + "version": "v0.9", + "updateDataModel": {"surfaceId": "test-surface", "value": data_model}, + } + + if payload is None: + return [] if catalog.version == VERSION_0_9 else {} + + return [payload] if catalog.version == VERSION_0_9 else payload + + def test_validate_duplicate_ids(self, test_catalog): + components = [ + {"id": "root", "component": "Text", "text": "Root"}, + {"id": "c1", "component": "Text", "text": "Hello"}, + {"id": "c1", "component": "Text", "text": "World"}, + ] + payload = self.make_payload(test_catalog, components=components) + with pytest.raises(ValueError, match="Duplicate component ID: c1"): + test_catalog.validator.validate(payload) + + def test_validate_missing_root(self, test_catalog): + # This payload has components but none are 'root' + # bypass make_payload as it adds root if missing + if test_catalog.version == VERSION_0_8: + payload = { + "surfaceUpdate": { + "surfaceId": "test", + "components": [{"id": "c1", "component": {"Text": {"text": "hi"}}}], + } + } + else: + payload = [{ + "version": "v0.9", + "updateComponents": { + "surfaceId": "test", + "components": [{"id": "c1", "component": "Text", "text": "hi"}], + }, + }] + + with pytest.raises(ValueError, match="Missing root component"): + test_catalog.validator.validate(payload) + + @pytest.mark.parametrize( + "component_type, field_name, ids_to_ref", + [ + ("Column", "children", ["missing"]), + ("Card", "child", "missing"), + ], + ) + def test_validate_dangling_references( + self, test_catalog, component_type, field_name, ids_to_ref + ): + components = [ + {"id": "root", "component": component_type, field_name: ids_to_ref}, + ] + payload = self.make_payload(test_catalog, components=components) + with pytest.raises(ValueError, match="references non-existent component"): + test_catalog.validator.validate(payload) + + def test_validate_self_reference(self, test_catalog): + components = [{"id": "root", "component": "Card", "child": "root"}] + payload = self.make_payload(test_catalog, components=components) + with pytest.raises(ValueError, match="Self-reference detected"): + test_catalog.validator.validate(payload) + + def test_validate_circular_reference(self, test_catalog): + components = [ + {"id": "root", "component": "Card", "child": "c1"}, + {"id": "c1", "component": "Card", "child": "root"}, + ] + payload = self.make_payload(test_catalog, components=components) + with pytest.raises(ValueError, match="Circular reference detected"): + test_catalog.validator.validate(payload) + + def test_validate_function_call_recursion(self, test_catalog): + deep_fc = {"call": "f0", "args": {}} + current = deep_fc["args"] + for i in range(10): + current["functionCall"] = {"call": f"f{i+1}", "args": {}} + current = current["functionCall"]["args"] + + components = [{ + "id": "root", + "component": "Button", + "text": "btn", + "action": {"functionCall": deep_fc}, + }] + # Button in StandardCatalog v0.9 requires 'text' and 'action' + payload = self.make_payload(test_catalog, components=components) + with pytest.raises( + ValueError, match="Recursion limit exceeded: functionCall depth > 5" + ): + test_catalog.validator.validate(payload) + + def test_validate_orphaned_component(self, test_catalog): + components = [ + {"id": "root", "component": "Text", "text": "Root"}, + {"id": "orphan", "component": "Text", "text": "Orphan"}, + ] + payload = self.make_payload(test_catalog, components=components) + with pytest.raises( + ValueError, match="Component 'orphan' is not reachable from 'root'" + ): + test_catalog.validator.validate(payload) + + def test_validate_recursion_limit_exceeded(self, test_catalog): + components = [{"id": "root", "component": "Card", "child": "c0"}] + for i in range(55): + components.append({"id": f"c{i}", "component": "Card", "child": f"c{i+1}"}) + components.append({"id": f"c{55}", "component": "Text", "text": "End"}) + + payload = self.make_payload(test_catalog, components=components) + with pytest.raises( + ValueError, match="Global recursion limit exceeded: logical depth" + ): + test_catalog.validator.validate(payload) + + def test_validate_recursion_limit_valid(self, test_catalog): + components = [{"id": "root", "component": "Card", "child": "c0"}] + for i in range(40): + components.append({"id": f"c{i}", "component": "Card", "child": f"c{i+1}"}) + components.append({"id": f"c{40}", "component": "Text", "text": "End"}) + + payload = self.make_payload(test_catalog, components=components) + test_catalog.validator.validate(payload) + + def test_validate_template_reachability(self, test_catalog): + # Verify that componentId inside a template is reachable + if test_catalog.version == VERSION_0_8: + # v0.8 mock Column expects an array of strings + comp_type = "Column" + children = ["template-id"] + else: + # v0.9 mock List expects an object with componentId and path + comp_type = "List" + children = {"componentId": "template-id", "path": "/items"} + + components = [ + { + "id": "root", + "component": comp_type, + "children": children, + }, + {"id": "template-id", "component": "Text", "text": "Reachable"}, + ] + payload = self.make_payload(test_catalog, components=components) + test_catalog.validator.validate(payload) + + # Verify that if the reference points to an invalid ID, it fails + if test_catalog.version == VERSION_0_8: + children_invalid = ["missing-id"] + else: + children_invalid = {"componentId": "missing-id", "path": "/items"} + + components_invalid = [ + { + "id": "root", + "component": comp_type, + "children": children_invalid, + }, + {"id": "template-id", "component": "Text", "text": "Reachable"}, + ] + payload_invalid = self.make_payload(test_catalog, components=components_invalid) + with pytest.raises( + ValueError, match="references non-existent component 'missing-id'" + ): + test_catalog.validator.validate(payload_invalid) + + def test_validate_v08_custom_root_reachability(self, test_catalog): + if test_catalog.version != VERSION_0_8: + pytest.skip("v0.8 specific test") + + # In v0.8, the root is determined by beginRendering.root + components = [ + {"id": "custom-root", "component": "Text", "text": "I am the root"}, + {"id": "orphan", "component": "Text", "text": "I am an orphan"}, + ] + # make_payload only gives us surfaceUpdate, we need to wrap it with beginRendering + surface_update = self.make_payload(test_catalog, components=components) + payload = [ + {"beginRendering": {"surfaceId": "test-surface", "root": "custom-root"}}, + surface_update, + ] + + # This should fail because 'orphan' is not reachable from 'custom-root' + with pytest.raises( + ValueError, match="Component 'orphan' is not reachable from 'custom-root'" + ): + test_catalog.validator.validate(payload) + + # Adding a reference to 'orphan' should make it pass + components_connected = [ + {"id": "custom-root", "component": "Card", "child": "orphan"}, + {"id": "orphan", "component": "Text", "text": "I am no longer an orphan"}, + ] + surface_update_connected = self.make_payload( + test_catalog, components=components_connected + ) + payload_connected = [ + {"beginRendering": {"surfaceId": "test-surface", "root": "custom-root"}}, + surface_update_connected, + ] + test_catalog.validator.validate(payload_connected) + + @pytest.mark.parametrize( + "payload", + [ + { + "updateDataModel": { + "surfaceId": "surface1", + "path": "invalid//path", + "value": {"some": "data"}, + } + }, + { + "updateComponents": { + "components": [{ + "id": "root", + "component": "Text", + "text": {"path": "invalid path with spaces"}, + }] + } + }, + { + "updateDataModel": { + "surfaceId": "surface1", + "path": "/invalid/escape/~2", + "value": {"some": "data"}, + } + }, + ], + ) + def test_validate_invalid_paths(self, test_catalog, payload): + # Use make_payload to ensure correct wrapping and 'version' field for v0.9 + if "updateComponents" in payload: + p = self.make_payload( + test_catalog, components=payload["updateComponents"]["components"] + ) + elif "updateDataModel" in payload: + # Inject surfaceId if missing to satisfy make_payload/schema + data = payload["updateDataModel"] + p = self.make_payload(test_catalog, data_model=data.get("value", {})) + # Override with test specific path + if test_catalog.version == VERSION_0_9: + p[0]["updateDataModel"]["path"] = data.get("path") + p[0]["updateDataModel"]["surfaceId"] = data.get("surfaceId", "surface1") + else: + p["dataModelUpdate"]["path"] = data.get("path") + p["dataModelUpdate"]["surfaceId"] = data.get("surfaceId", "surface1") + + with pytest.raises( + ValueError, + match=( + "(Invalid JSON Pointer syntax|is not valid under any of the given schemas)" + ), + ): + test_catalog.validator.validate(p) + + def test_validate_global_recursion_limit_exceeded(self, test_catalog): + deep_data = {"level": 0} + current = deep_data + for i in range(55): + current["next"] = {"level": i + 1} + current = current["next"] + + # Generic payload that results in deep dict + payload = self.make_payload(test_catalog, data_model=deep_data) + with pytest.raises(ValueError, match="Global recursion limit exceeded"): + test_catalog.validator.validate(payload) 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 ed8f4e6d1..2549c58b4 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 @@ -38,7 +38,7 @@ def verify(): 'id': 'profile_image', 'component': { 'Image': { - 'url': {'path': 'imageUrl'}, + 'url': {'path': '/imageUrl'}, 'usageHint': 'avatar', 'fit': 'cover', } @@ -48,16 +48,16 @@ def verify(): 'id': 'user_heading', 'weight': 1, 'component': { - 'Text': {'text': {'path': 'name'}, 'usageHint': 'h2'} + 'Text': {'text': {'path': '/name'}, 'usageHint': 'h2'} }, }, { 'id': 'description_text_1', - 'component': {'Text': {'text': {'path': 'title'}}}, + 'component': {'Text': {'text': {'path': '/title'}}}, }, { 'id': 'description_text_2', - 'component': {'Text': {'text': {'path': 'team'}}}, + 'component': {'Text': {'text': {'path': '/team'}}}, }, { 'id': 'description_column', @@ -83,7 +83,7 @@ def verify(): { 'id': 'calendar_primary_text', 'component': { - 'Text': {'usageHint': 'h5', 'text': {'path': 'calendar'}} + 'Text': {'usageHint': 'h5', 'text': {'path': '/calendar'}} }, }, { @@ -129,7 +129,7 @@ def verify(): { 'id': 'location_primary_text', 'component': { - 'Text': {'usageHint': 'h5', 'text': {'path': 'location'}} + 'Text': {'usageHint': 'h5', 'text': {'path': '/location'}} }, }, { @@ -173,7 +173,7 @@ def verify(): { 'id': 'mail_primary_text', 'component': { - 'Text': {'usageHint': 'h5', 'text': {'path': 'email'}} + 'Text': {'usageHint': 'h5', 'text': {'path': '/email'}} }, }, { @@ -215,7 +215,7 @@ def verify(): { 'id': 'call_primary_text', 'component': { - 'Text': {'usageHint': 'h5', 'text': {'path': 'mobile'}} + 'Text': {'usageHint': 'h5', 'text': {'path': '/mobile'}} }, }, { diff --git a/a2a_agents/python/a2ui_agent/tests/test_validation.py b/a2a_agents/python/a2ui_agent/tests/test_validation.py deleted file mode 100644 index afd642e58..000000000 --- a/a2a_agents/python/a2ui_agent/tests/test_validation.py +++ /dev/null @@ -1,358 +0,0 @@ -import pytest -import jsonschema -from a2ui.extension.validation import validate_a2ui_json - - -# Fixture for the schema -@pytest.fixture -def schema(): - return { - "type": "object", - "$defs": { - "ComponentId": {"type": "string"}, - "ChildList": {"type": "array", "items": {"$ref": "#/$defs/ComponentId"}}, - }, - "properties": { - "components": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": {"$ref": "#/$defs/ComponentId"}, - "componentProperties": { - "type": "object", - "properties": { - "Column": { - "type": "object", - "properties": { - "children": {"$ref": "#/$defs/ChildList"} - }, - }, - "Row": { - "type": "object", - "properties": { - "children": {"$ref": "#/$defs/ChildList"} - }, - }, - "Container": { - "type": "object", - "properties": { - "children": {"$ref": "#/$defs/ChildList"} - }, - }, - "Card": { - "type": "object", - "properties": { - "child": {"$ref": "#/$defs/ComponentId"} - }, - }, - "Button": { - "type": "object", - "properties": { - "child": {"$ref": "#/$defs/ComponentId"}, - "action": { - "properties": { - "functionCall": { - "properties": { - "call": {"type": "string"}, - "args": {"type": "object"}, - } - } - } - }, - }, - }, - "Text": { - "type": "object", - "properties": { - "text": { - "oneOf": [ - {"type": "string"}, - {"type": "object"}, - ] - } - }, - }, - }, - }, - }, - "required": ["id"], - }, - } - }, - } - - -def test_validate_a2ui_json_valid_integrity(schema): - payload = { - "components": [ - {"id": "root", "componentProperties": {"Column": {"children": ["child1"]}}}, - {"id": "child1", "componentProperties": {"Text": {"text": "Hello"}}}, - ] - } - validate_a2ui_json(payload, schema) - - -def test_validate_a2ui_json_duplicate_ids(schema): - payload = { - "components": [ - {"id": "root", "componentProperties": {}}, - {"id": "root", "componentProperties": {}}, - ] - } - with pytest.raises(ValueError, match="Duplicate component ID found: 'root'"): - validate_a2ui_json(payload, schema) - - -def test_validate_a2ui_json_missing_root(schema): - payload = {"components": [{"id": "not-root", "componentProperties": {}}]} - with pytest.raises(ValueError, match="Missing 'root' component"): - validate_a2ui_json(payload, schema) - - -@pytest.mark.parametrize( - "component_type, field_name, ids_to_ref", - [ - ("Card", "child", "missing_child"), - ("Column", "children", ["child1", "missing_child"]), - ], -) -def test_validate_a2ui_json_dangling_references( - schema, component_type, field_name, ids_to_ref -): - """Test dangling references for both single and list fields.""" - # Construct payload dynamically - props = {field_name: ids_to_ref} - payload = { - "components": [{"id": "root", "componentProperties": {component_type: props}}] - } - if isinstance(ids_to_ref, list): - # Add valid children if any - for child_id in ids_to_ref: - if child_id != "missing_child": - payload["components"].append({"id": child_id, "componentProperties": {}}) - - with pytest.raises( - ValueError, - match=( - "Component 'root' references missing ID 'missing_child' in field" - f" '{field_name}'" - ), - ): - validate_a2ui_json(payload, schema) - - -def test_validate_a2ui_json_self_reference(schema): - payload = { - "components": [ - {"id": "root", "componentProperties": {"Container": {"children": ["root"]}}} - ] - } - with pytest.raises( - ValueError, - match=( - "Self-reference detected: Component 'root' references itself in field" - " 'children'" - ), - ): - validate_a2ui_json(payload, schema) - - -def test_validate_a2ui_json_circular_reference(schema): - payload = { - "components": [ - { - "id": "root", - "componentProperties": {"Container": {"children": ["child1"]}}, - }, - { - "id": "child1", - "componentProperties": {"Container": {"children": ["root"]}}, - }, - ] - } - with pytest.raises( - ValueError, match="Circular reference detected involving component" - ): - validate_a2ui_json(payload, schema) - - -def test_validate_a2ui_json_orphaned_component(schema): - payload = { - "components": [ - {"id": "root", "componentProperties": {"Container": {"children": []}}}, - {"id": "orphan", "componentProperties": {}}, - ] - } - with pytest.raises( - ValueError, - match=r"Orphaned components detected \(not reachable from 'root'\): \['orphan'\]", - ): - validate_a2ui_json(payload, schema) - - -def test_validate_a2ui_json_valid_topology_complex(schema): - """Test a valid topology with multiple levels.""" - payload = { - "components": [ - { - "id": "root", - "componentProperties": {"Container": {"children": ["child1", "child2"]}}, - }, - {"id": "child1", "componentProperties": {"Text": {"text": "Hello"}}}, - { - "id": "child2", - "componentProperties": {"Container": {"children": ["child3"]}}, - }, - {"id": "child3", "componentProperties": {"Text": {"text": "World"}}}, - ] - } - validate_a2ui_json(payload, schema) - - -def test_validate_recursion_limit_exceeded(schema): - """Test that recursion depth > 5 raises ValueError.""" - # Construct deep function call - args = {} - current = args - for i in range(5): # Depth 0 to 5 (6 levels) - current["arg"] = {"call": f"fn{i}", "args": {}} - current = current["arg"]["args"] - - payload = { - "components": [{ - "id": "root", - "componentProperties": { - "Button": { - "label": "Click me", - "action": {"functionCall": {"call": "fn_top", "args": args}}, - } - }, - }] - } - with pytest.raises(ValueError, match="Recursion limit exceeded"): - validate_a2ui_json(payload, schema) - - -def test_validate_recursion_limit_valid(schema): - """Test that recursion depth <= 5 is allowed.""" - # Construct max depth function call (Depth 5) - args = {} - current = args - for i in range(4): # Depth 0 to 4 (5 levels) - current["arg"] = {"call": f"fn{i}", "args": {}} - current = current["arg"]["args"] - - payload = { - "components": [{ - "id": "root", - "componentProperties": { - "Button": { - "label": "Click me", - "action": {"functionCall": {"call": "fn_top", "args": args}}, - } - }, - }] - } - validate_a2ui_json(payload, schema) - - -@pytest.mark.parametrize( - "payload", - [ - { - "updateDataModel": { - "surfaceId": "surface1", - "path": "invalid//path", - "value": "data", - } - }, - { - "components": [{ - "id": "root", - "componentProperties": { - "Text": {"text": {"path": "invalid path with spaces"}} - }, - }] - }, - { - "updateDataModel": { - "surfaceId": "surface1", - "path": "/invalid/escape/~2", - "value": "data", - } - }, - ], -) -def test_validate_invalid_paths(schema, payload): - """Test various invalid paths (JSON Pointer syntax).""" - with pytest.raises(ValueError, match="Invalid JSON Pointer syntax"): - validate_a2ui_json(payload, schema) - - -def test_validate_global_recursion_limit_exceeded(schema): - """Test that global recursion depth > 50 raises ValueError.""" - # Create a deeply nested dictionary - deep_payload = {"level": 0} - current = deep_payload - for i in range(55): - current["next"] = {"level": i + 1} - current = current["next"] - - with pytest.raises(ValueError, match="Global recursion limit exceeded"): - validate_a2ui_json(deep_payload, schema) - - -def test_validate_custom_schema_reference(): - """Test validation with a custom schema where a component has a non-standard reference field.""" - # Custom schema extending the base one - custom_schema = { - "type": "object", - "$defs": { - "ComponentId": {"type": "string"}, - "ChildList": {"type": "array", "items": {"$ref": "#/$defs/ComponentId"}}, - }, - "properties": { - "components": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": {"$ref": "#/$defs/ComponentId"}, - "componentProperties": { - "type": "object", - "properties": { - "CustomLink": { - "type": "object", - "properties": { - "linkedComponentId": { - "$ref": "#/$defs/ComponentId" - } - }, - } - }, - }, - }, - "required": ["id"], - }, - } - }, - } - - payload = { - "components": [{ - "id": "root", - "componentProperties": { - "CustomLink": {"linkedComponentId": "missing_target"} - }, - }] - } - - with pytest.raises( - ValueError, - match=( - "Component 'root' references missing ID 'missing_target' in field" - " 'linkedComponentId'" - ), - ): - validate_a2ui_json(payload, custom_schema) diff --git a/samples/agent/adk/contact_lookup/examples/action_confirmation.json b/samples/agent/adk/contact_lookup/examples/action_confirmation.json index f961363d7..74a665a04 100644 --- a/samples/agent/adk/contact_lookup/examples/action_confirmation.json +++ b/samples/agent/adk/contact_lookup/examples/action_confirmation.json @@ -1,23 +1,112 @@ [ - { "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." } - ] - } } + { + "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 index 70f0787b3..384b2626c 100644 --- a/samples/agent/adk/contact_lookup/examples/contact_card.json +++ b/samples/agent/adk/contact_lookup/examples/contact_card.json @@ -1,54 +1,490 @@ [ - { "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": "" } - ] - } } + { + "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 index 4f87d069b..2cf52bede 100644 --- a/samples/agent/adk/contact_lookup/examples/contact_list.json +++ b/samples/agent/adk/contact_lookup/examples/contact_list.json @@ -79,7 +79,7 @@ "component": { "Image": { "url": { - "path": "imageUrl" + "path": "/imageUrl" }, "fit": "cover" } @@ -104,7 +104,7 @@ "Text": { "usageHint": "h3", "text": { - "path": "name" + "path": "/name" } } } @@ -114,7 +114,7 @@ "component": { "Text": { "text": { - "path": "title" + "path": "/title" } } } @@ -141,13 +141,13 @@ { "key": "contactName", "value": { - "path": "name" + "path": "/name" } }, { "key": "department", "value": { - "path": "department" + "path": "/department" } } ] diff --git a/samples/agent/adk/contact_multiple_surfaces/examples/action_confirmation.json b/samples/agent/adk/contact_multiple_surfaces/examples/action_confirmation.json index 76ea5406d..74a665a04 100644 --- a/samples/agent/adk/contact_multiple_surfaces/examples/action_confirmation.json +++ b/samples/agent/adk/contact_multiple_surfaces/examples/action_confirmation.json @@ -53,7 +53,7 @@ "Text": { "usageHint": "h2", "text": { - "path": "actionTitle" + "path": "/actionTitle" } } } @@ -63,7 +63,7 @@ "component": { "Text": { "text": { - "path": "actionMessage" + "path": "/actionMessage" } } } diff --git a/samples/agent/adk/contact_multiple_surfaces/examples/chart_node_click.json b/samples/agent/adk/contact_multiple_surfaces/examples/chart_node_click.json index 2671ccb7d..c4c6bfb03 100644 --- a/samples/agent/adk/contact_multiple_surfaces/examples/chart_node_click.json +++ b/samples/agent/adk/contact_multiple_surfaces/examples/chart_node_click.json @@ -8,7 +8,7 @@ "component": { "Image": { "url": { - "path": "imageUrl" + "path": "/imageUrl" }, "usageHint": "avatar", "fit": "cover" @@ -21,7 +21,7 @@ "component": { "Text": { "text": { - "path": "name" + "path": "/name" }, "usageHint": "h2" } @@ -32,7 +32,7 @@ "component": { "Text": { "text": { - "path": "title" + "path": "/title" } } } @@ -42,7 +42,7 @@ "component": { "Text": { "text": { - "path": "team" + "path": "/team" } } } @@ -78,7 +78,7 @@ "Text": { "usageHint": "h5", "text": { - "path": "calendar" + "path": "/calendar" } } } @@ -139,7 +139,7 @@ "Text": { "usageHint": "h5", "text": { - "path": "location" + "path": "/location" } } } @@ -200,7 +200,7 @@ "Text": { "usageHint": "h5", "text": { - "path": "email" + "path": "/email" } } } @@ -267,7 +267,7 @@ "Text": { "usageHint": "h5", "text": { - "path": "mobile" + "path": "/mobile" } } } @@ -350,7 +350,7 @@ { "key": "contactName", "value": { - "path": "name" + "path": "/name" } } ] @@ -379,7 +379,7 @@ { "key": "contactId", "value": { - "path": "id" + "path": "/id" } } ] diff --git a/samples/agent/adk/contact_multiple_surfaces/examples/contact_card.json b/samples/agent/adk/contact_multiple_surfaces/examples/contact_card.json index 4d54da92c..12f77e644 100644 --- a/samples/agent/adk/contact_multiple_surfaces/examples/contact_card.json +++ b/samples/agent/adk/contact_multiple_surfaces/examples/contact_card.json @@ -14,7 +14,7 @@ "component": { "Image": { "url": { - "path": "imageUrl" + "path": "/imageUrl" }, "usageHint": "avatar", "fit": "cover" @@ -27,7 +27,7 @@ "component": { "Text": { "text": { - "path": "name" + "path": "/name" }, "usageHint": "h2" } @@ -38,7 +38,7 @@ "component": { "Text": { "text": { - "path": "title" + "path": "/title" } } } @@ -48,7 +48,7 @@ "component": { "Text": { "text": { - "path": "team" + "path": "/team" } } } @@ -84,7 +84,7 @@ "Text": { "usageHint": "h5", "text": { - "path": "calendar" + "path": "/calendar" } } } @@ -145,7 +145,7 @@ "Text": { "usageHint": "h5", "text": { - "path": "location" + "path": "/location" } } } @@ -206,7 +206,7 @@ "Text": { "usageHint": "h5", "text": { - "path": "email" + "path": "/email" } } } @@ -273,7 +273,7 @@ "Text": { "usageHint": "h5", "text": { - "path": "mobile" + "path": "/mobile" } } } @@ -356,7 +356,7 @@ { "key": "contactName", "value": { - "path": "name" + "path": "/name" } } ] @@ -385,7 +385,7 @@ { "key": "contactId", "value": { - "path": "id" + "path": "/id" } } ] diff --git a/samples/agent/adk/contact_multiple_surfaces/examples/contact_list.json b/samples/agent/adk/contact_multiple_surfaces/examples/contact_list.json index 4f87d069b..2cf52bede 100644 --- a/samples/agent/adk/contact_multiple_surfaces/examples/contact_list.json +++ b/samples/agent/adk/contact_multiple_surfaces/examples/contact_list.json @@ -79,7 +79,7 @@ "component": { "Image": { "url": { - "path": "imageUrl" + "path": "/imageUrl" }, "fit": "cover" } @@ -104,7 +104,7 @@ "Text": { "usageHint": "h3", "text": { - "path": "name" + "path": "/name" } } } @@ -114,7 +114,7 @@ "component": { "Text": { "text": { - "path": "title" + "path": "/title" } } } @@ -141,13 +141,13 @@ { "key": "contactName", "value": { - "path": "name" + "path": "/name" } }, { "key": "department", "value": { - "path": "department" + "path": "/department" } } ] diff --git a/samples/agent/adk/contact_multiple_surfaces/examples/multi_surface.json b/samples/agent/adk/contact_multiple_surfaces/examples/multi_surface.json index e57d20a9b..ffa723cc6 100644 --- a/samples/agent/adk/contact_multiple_surfaces/examples/multi_surface.json +++ b/samples/agent/adk/contact_multiple_surfaces/examples/multi_surface.json @@ -20,7 +20,7 @@ "component": { "Image": { "url": { - "path": "imageUrl" + "path": "/imageUrl" }, "usageHint": "avatar", "fit": "cover" @@ -33,7 +33,7 @@ "component": { "Text": { "text": { - "path": "name" + "path": "/name" }, "usageHint": "h2" } @@ -44,7 +44,7 @@ "component": { "Text": { "text": { - "path": "title" + "path": "/title" } } } @@ -54,7 +54,7 @@ "component": { "Text": { "text": { - "path": "team" + "path": "/team" } } } @@ -90,7 +90,7 @@ "Text": { "usageHint": "h5", "text": { - "path": "calendar" + "path": "/calendar" } } } @@ -151,7 +151,7 @@ "Text": { "usageHint": "h5", "text": { - "path": "location" + "path": "/location" } } } @@ -212,7 +212,7 @@ "Text": { "usageHint": "h5", "text": { - "path": "email" + "path": "/email" } } } @@ -279,7 +279,7 @@ "Text": { "usageHint": "h5", "text": { - "path": "mobile" + "path": "/mobile" } } } @@ -362,7 +362,7 @@ { "key": "contactName", "value": { - "path": "name" + "path": "/name" } } ] @@ -391,7 +391,7 @@ { "key": "contactId", "value": { - "path": "id" + "path": "/id" } } ] @@ -463,7 +463,7 @@ "component": { "OrgChart": { "chain": { - "path": "hierarchy" + "path": "/hierarchy" }, "action": { "name": "chart_node_click", diff --git a/samples/agent/adk/contact_multiple_surfaces/examples/org_chart.json b/samples/agent/adk/contact_multiple_surfaces/examples/org_chart.json index 5eace0e97..bc3c7f5fe 100644 --- a/samples/agent/adk/contact_multiple_surfaces/examples/org_chart.json +++ b/samples/agent/adk/contact_multiple_surfaces/examples/org_chart.json @@ -38,7 +38,7 @@ "component": { "OrgChart": { "chain": { - "path": "hierarchy" + "path": "/hierarchy" }, "action": { "name": "chart_node_click", diff --git a/samples/agent/adk/restaurant_finder/examples/booking_form.json b/samples/agent/adk/restaurant_finder/examples/booking_form.json index e144b78b7..69c805857 100644 --- a/samples/agent/adk/restaurant_finder/examples/booking_form.json +++ b/samples/agent/adk/restaurant_finder/examples/booking_form.json @@ -1,30 +1,199 @@ [ - { "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": "" } - ] - }} + { + "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 index 049e4b9c8..d91dca3e0 100644 --- a/samples/agent/adk/restaurant_finder/examples/confirmation.json +++ b/samples/agent/adk/restaurant_finder/examples/confirmation.json @@ -1,27 +1,121 @@ [ - { "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]" } - ] - }} + { + "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 index 377d1b6dc..a9d0f272d 100644 --- a/samples/agent/adk/restaurant_finder/examples/single_column_list.json +++ b/samples/agent/adk/restaurant_finder/examples/single_column_list.json @@ -1,45 +1,260 @@ [ - { "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" } - ] } - ] } - ] - }} + { + "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 index 51e479699..25cae4845 100644 --- a/samples/agent/adk/restaurant_finder/examples/two_column_list.json +++ b/samples/agent/adk/restaurant_finder/examples/two_column_list.json @@ -1,56 +1,393 @@ [ - { "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" } - ] } - ] } - ] - }} + { + "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/rizzcharts/__main__.py b/samples/agent/adk/rizzcharts/__main__.py index 7b9481214..5ac4a7e16 100644 --- a/samples/agent/adk/rizzcharts/__main__.py +++ b/samples/agent/adk/rizzcharts/__main__.py @@ -21,7 +21,8 @@ from a2a.server.apps import A2AStarletteApplication from a2a.server.request_handlers import DefaultRequestHandler from a2a.server.tasks import InMemoryTaskStore -from agent_executor import RizzchartsAgentExecutor, get_a2ui_enabled, get_a2ui_schema +from a2ui.inference.schema.manager import A2uiSchemaManager, CustomCatalogConfig +from agent_executor import RizzchartsAgentExecutor, get_a2ui_enabled, get_a2ui_catalog, get_a2ui_examples from agent import RizzchartsAgent from google.adk.artifacts import InMemoryArtifactService from google.adk.memory.in_memory_memory_service import InMemoryMemoryService @@ -55,10 +56,29 @@ def main(host, port): ) lite_llm_model = os.getenv("LITELLM_MODEL", "gemini/gemini-2.5-flash") + + base_url = f"http://{host}:{port}" + + schema_manager = A2uiSchemaManager( + version="0.8", + basic_examples_path="examples/standard_catalog", + custom_catalogs=[ + CustomCatalogConfig( + name="rizzcharts", + catalog_path="rizzcharts_catalog_definition.json", + examples_path="examples/rizzcharts_catalog", + ) + ], + accepts_inline_catalogs=True, + ) + agent = RizzchartsAgent( + base_url=base_url, model=LiteLlm(model=lite_llm_model), + schema_manager=schema_manager, a2ui_enabled_provider=get_a2ui_enabled, - a2ui_schema_provider=get_a2ui_schema, + a2ui_catalog_provider=get_a2ui_catalog, + a2ui_examples_provider=get_a2ui_examples, ) runner = Runner( app_name=agent.name, @@ -68,30 +88,10 @@ def main(host, port): memory_service=InMemoryMemoryService(), ) - current_dir = pathlib.Path(__file__).resolve().parent - spec_root = current_dir / "../../../../specification/v0_8/json" - - try: - a2ui_schema_content = (spec_root / "server_to_client.json").read_text() - standard_catalog_content = ( - spec_root / "standard_catalog_definition.json" - ).read_text() - rizzcharts_catalog_content = ( - current_dir / "rizzcharts_catalog_definition.json" - ).read_text() - except FileNotFoundError as e: - logger.error(f"Failed to load required JSON files: {e}") - exit(1) - - logger.info(f"Loaded schema from {spec_root}") - - base_url = f"http://{host}:{port}" agent_executor = RizzchartsAgentExecutor( base_url=base_url, runner=runner, - a2ui_schema_content=a2ui_schema_content, - standard_catalog_content=standard_catalog_content, - rizzcharts_catalog_content=rizzcharts_catalog_content, + schema_manager=schema_manager, ) request_handler = DefaultRequestHandler( @@ -99,7 +99,7 @@ def main(host, port): task_store=InMemoryTaskStore(), ) server = A2AStarletteApplication( - agent_card=agent_executor.get_agent_card(), http_handler=request_handler + agent_card=agent.get_agent_card(), http_handler=request_handler ) import uvicorn diff --git a/samples/agent/adk/rizzcharts/agent.py b/samples/agent/adk/rizzcharts/agent.py index a1958d4fb..7c42580df 100644 --- a/samples/agent/adk/rizzcharts/agent.py +++ b/samples/agent/adk/rizzcharts/agent.py @@ -17,10 +17,10 @@ from pathlib import Path import pkgutil from typing import Any, ClassVar - +from a2a.types import AgentCapabilities, AgentCard, AgentSkill from a2ui.extension.a2ui_extension import STANDARD_CATALOG_ID -from a2ui.extension.a2ui_schema_utils import wrap_as_json_array -from a2ui.extension.send_a2ui_to_client_toolset import SendA2uiToClientToolset, A2uiEnabledProvider, A2uiSchemaProvider +from a2ui.extension.send_a2ui_to_client_toolset import SendA2uiToClientToolset, A2uiEnabledProvider, A2uiCatalogProvider, A2uiExamplesProvider +from a2ui.inference.schema.manager import A2uiSchemaManager from google.adk.agents.llm_agent import LlmAgent from google.adk.agents.readonly_context import ReadonlyContext from google.adk.planners.built_in_planner import BuiltInPlanner @@ -36,40 +36,103 @@ logger = logging.getLogger(__name__) RIZZCHARTS_CATALOG_URI = "https://github.com/google/A2UI/blob/main/samples/agent/adk/rizzcharts/rizzcharts_catalog_definition.json" -A2UI_CATALOG_URI_STATE_KEY = "user:a2ui_catalog_uri" + +ROLE_DESCRIPTION = """ +You are an expert A2UI Ecommerce Dashboard analyst. Your primary function is to translate user requests for ecommerce data into A2UI JSON payloads to display charts and visualizations. You MUST use the `send_a2ui_json_to_client` tool with the `a2ui_json` argument set to the A2UI JSON payload to send to the client. You should also include a brief text message with each response saying what you did and asking if you can help with anything else. +""" + +WORKFLOW_DESCRIPTION = """ +Your task is to analyze the user's request, fetch the necessary data, select the correct generic template, and send the corresponding A2UI JSON payload. + +1. **Analyze the Request:** Determine the user's intent (Visual Chart vs. Geospatial Map). + * "show my sales breakdown by product category for q3" -> **Intent:** Chart. + * "show revenue trends yoy by month" -> **Intent:** Chart. + * "were there any outlier stores in the northeast region" -> **Intent:** Map. + +2. **Fetch Data:** Select and use the appropriate tool to retrieve the necessary data. + * Use **`get_sales_data`** for general sales, revenue, and product category trends (typically for Charts). + * Use **`get_store_sales`** for regional performance, store locations, and geospatial outliers (typically for Maps). + +3. **Select Example:** Based on the intent, choose the correct example block to use as your template. + * **Intent** (Chart/Data Viz) -> Use `---BEGIN CHART EXAMPLE---`. + * **Intent** (Map/Geospatial) -> Use `---BEGIN MAP EXAMPLE---`. + +4. **Construct the JSON Payload:** + * Use the **entire** JSON array from the chosen example as the base value for the `a2ui_json` argument. + * **Generate a new `surfaceId`:** You MUST generate a new, unique `surfaceId` for this request (e.g., `sales_breakdown_q3_surface`, `regional_outliers_northeast_surface`). This new ID must be used for the `surfaceId` in all three messages within the JSON array (`beginRendering`, `surfaceUpdate`, `dataModelUpdate`). + * **Update the title Text:** You MUST update the `literalString` value for the `Text` component (the component with `id: "page_header"`) to accurately reflect the specific user query. For example, if the user asks for "Q3" sales, update the generic template text to "Q3 2025 Sales by Product Category". + * Ensure the generated JSON perfectly matches the A2UI specification. It will be validated against the json_schema and rejected if it does not conform. + * If you get an error in the tool response apologize to the user and let them know they should try again. + +5. **Call the Tool:** Call the `send_a2ui_json_to_client` tool with the fully constructed `a2ui_json` payload. +""" + +UI_DESCRIPTION = """ +**Core Objective:** To provide a dynamic and interactive dashboard by constructing UI surfaces with the appropriate visualization components based on user queries. + +**Key Components & Examples:** + +You will be provided a schema that defines the A2UI message structure and two key generic component templates for displaying data. + +1. **Charts:** Used for requests about sales breakdowns, revenue performance, comparisons, or trends. + * **Template:** Use the JSON from `---BEGIN CHART EXAMPLE---`. +2. **Maps:** Used for requests about regional data, store locations, geography-based performance, or regional outliers. + * **Template:** Use the JSON from `---BEGIN MAP EXAMPLE---`. + +You will also use layout components like `Column` (as the `root`) and `Text` (to provide a title). +""" class RizzchartsAgent(LlmAgent): """An agent that runs an ecommerce dashboard""" SUPPORTED_CONTENT_TYPES: ClassVar[list[str]] = ["text", "text/plain"] + base_url: str = "" + schema_manager: A2uiSchemaManager = None _a2ui_enabled_provider: A2uiEnabledProvider = PrivateAttr() - _a2ui_schema_provider: A2uiSchemaProvider = PrivateAttr() + _a2ui_catalog_provider: A2uiCatalogProvider = PrivateAttr() + _a2ui_examples_provider: A2uiExamplesProvider = PrivateAttr() def __init__( self, model: Any, + base_url: str, + schema_manager: A2uiSchemaManager, a2ui_enabled_provider: A2uiEnabledProvider, - a2ui_schema_provider: A2uiSchemaProvider, + a2ui_catalog_provider: A2uiCatalogProvider, + a2ui_examples_provider: A2uiExamplesProvider, ): """Initializes the RizzchartsAgent. Args: model: The LLM model to use. + base_url: The base URL for the agent. + schema_manager: The A2UI schema manager. a2ui_enabled_provider: A provider to check if A2UI is enabled. - a2ui_schema_provider: A provider to retrieve the A2UI schema. + a2ui_catalog_provider: A provider to retrieve the A2UI catalog (A2uiCatalog object). + a2ui_examples_provider: A provider to retrieve the A2UI examples (str). """ + + system_instructions = schema_manager.generate_system_prompt( + role_description=ROLE_DESCRIPTION, + workflow_description=WORKFLOW_DESCRIPTION, + ui_description=UI_DESCRIPTION, + include_schema=False, + include_examples=False, + validate_examples=False, + ) super().__init__( model=model, name="rizzcharts_agent", description="An agent that lets sales managers request sales data.", - instruction=self.get_instructions, + instruction=system_instructions, tools=[ get_store_sales, get_sales_data, SendA2uiToClientToolset( - a2ui_schema=a2ui_schema_provider, + a2ui_catalog=a2ui_catalog_provider, a2ui_enabled=a2ui_enabled_provider, + a2ui_examples=a2ui_examples_provider, ), ], planner=BuiltInPlanner( @@ -78,142 +141,60 @@ def __init__( ) ), disallow_transfer_to_peers=True, + base_url=base_url, + schema_manager=schema_manager, ) self._a2ui_enabled_provider = a2ui_enabled_provider - self._a2ui_schema_provider = a2ui_schema_provider + self._a2ui_catalog_provider = a2ui_catalog_provider + self._a2ui_examples_provider = a2ui_examples_provider - def get_a2ui_schema(self, ctx: ReadonlyContext) -> dict[str, Any]: - """Retrieves and wraps the A2UI schema from the session state. - - Args: - ctx: The ReadonlyContext for resolving the schema. + def get_agent_card(self) -> AgentCard: + """Returns the AgentCard defining this agent's metadata and skills. Returns: - The wrapped A2UI schema. + An AgentCard object. """ - a2ui_schema = self._a2ui_schema_provider(ctx) - return wrap_as_json_array(a2ui_schema) - - def load_example(self, path: str, a2ui_schema: dict[str, Any]) -> dict[str, Any]: - """Loads an example JSON file and validates it against the A2UI schema. - - Args: - path: Relative path to the example JSON file. - a2ui_schema: The A2UI schema to validate against. - - Returns: - The loaded and validated JSON data. - """ - data = None - try: - # Try pkgutil first (for Google3) - package_name = __package__ or "" - data = pkgutil.get_data(package_name, path) - except ImportError: - logger.info("pkgutil failed to get data, falling back to file system.") - - if data: - example_str = data.decode("utf-8") - else: - # Fallback to direct Path relative to this file (for local dev) - full_path = Path(__file__).parent / path - example_str = full_path.read_text() - - example_json = json.loads(example_str) - jsonschema.validate(instance=example_json, schema=a2ui_schema) - return example_json - - def get_instructions(self, readonly_context: ReadonlyContext) -> str: - """Generates the system instructions for the agent. - - Args: - readonly_context: The ReadonlyContext for resolving instructions. - - Returns: - The generated system instructions. - """ - use_ui = self._a2ui_enabled_provider(readonly_context) - if not use_ui: - raise ValueError("A2UI must be enabled to run rizzcharts agent") - - a2ui_schema = self.get_a2ui_schema(readonly_context) - catalog_uri = readonly_context.state.get(A2UI_CATALOG_URI_STATE_KEY) - if catalog_uri == RIZZCHARTS_CATALOG_URI: - map_example = self.load_example( - "examples/rizzcharts_catalog/map.json", a2ui_schema - ) - chart_example = self.load_example( - "examples/rizzcharts_catalog/chart.json", a2ui_schema - ) - elif catalog_uri == STANDARD_CATALOG_ID: - map_example = self.load_example("examples/standard_catalog/map.json", a2ui_schema) - chart_example = self.load_example( - "examples/standard_catalog/chart.json", a2ui_schema - ) - else: - raise ValueError( - f"Unsupported catalog uri: {catalog_uri if catalog_uri else 'None'}" - ) - - final_prompt = f""" -### System Instructions - -You are an expert A2UI Ecommerce Dashboard analyst. Your primary function is to translate user requests for ecommerce data into A2UI JSON payloads to display charts and visualizations. You MUST use the `send_a2ui_json_to_client` tool with the `a2ui_json` argument set to the A2UI JSON payload to send to the client. You should also include a brief text message with each response saying what you did and asking if you can help with anything else. - -**Core Objective:** To provide a dynamic and interactive dashboard by constructing UI surfaces with the appropriate visualization components based on user queries. - -**Key Components & Examples:** - -You will be provided a schema that defines the A2UI message structure and two key generic component templates for displaying data. - -1. **Charts:** Used for requests about sales breakdowns, revenue performance, comparisons, or trends. - * **Template:** Use the JSON from `---BEGIN CHART EXAMPLE---`. -2. **Maps:** Used for requests about regional data, store locations, geography-based performance, or regional outliers. - * **Template:** Use the JSON from `---BEGIN MAP EXAMPLE---`. - -You will also use layout components like `Column` (as the `root`) and `Text` (to provide a title). - ---- - -### Workflow and Rules - -Your task is to analyze the user's request, fetch the necessary data, select the correct generic template, and send the corresponding A2UI JSON payload. - -1. **Analyze the Request:** Determine the user's intent (Visual Chart vs. Geospatial Map). - * "show my sales breakdown by product category for q3" -> **Intent:** Chart. - * "show revenue trends yoy by month" -> **Intent:** Chart. - * "were there any outlier stores in the northeast region" -> **Intent:** Map. - -2. **Fetch Data:** Select and use the appropriate tool to retrieve the necessary data. - * Use **`get_sales_data`** for general sales, revenue, and product category trends (typically for Charts). - * Use **`get_store_sales`** for regional performance, store locations, and geospatial outliers (typically for Maps). - -3. **Select Example:** Based on the intent, choose the correct example block to use as your template. - * **Intent** (Chart/Data Viz) -> Use `---BEGIN CHART EXAMPLE---`. - * **Intent** (Map/Geospatial) -> Use `---BEGIN MAP EXAMPLE---`. - -4. **Construct the JSON Payload:** - * Use the **entire** JSON array from the chosen example as the base value for the `a2ui_json` argument. - * **Generate a new `surfaceId`:** You MUST generate a new, unique `surfaceId` for this request (e.g., `sales_breakdown_q3_surface`, `regional_outliers_northeast_surface`). This new ID must be used for the `surfaceId` in all three messages within the JSON array (`beginRendering`, `surfaceUpdate`, `dataModelUpdate`). - * **Update the title Text:** You MUST update the `literalString` value for the `Text` component (the component with `id: "page_header"`) to accurately reflect the specific user query. For example, if the user asks for "Q3" sales, update the generic template text to "Q3 2025 Sales by Product Category". - * Ensure the generated JSON perfectly matches the A2UI specification. It will be validated against the json_schema and rejected if it does not conform. - * If you get an error in the tool response apologize to the user and let them know they should try again. - -5. **Call the Tool:** Call the `send_a2ui_json_to_client` tool with the fully constructed `a2ui_json` payload. - ----BEGIN CHART EXAMPLE--- -{json.dumps(chart_example)} ----END CHART EXAMPLE--- - ----BEGIN MAP EXAMPLE--- -{json.dumps(map_example)} ----END MAP EXAMPLE--- -""" - - logger.info( - f"Generated system instructions for A2UI {'ENABLED' if use_ui else 'DISABLED'}" - f" and catalog {catalog_uri}" + return AgentCard( + name="Ecommerce Dashboard Agent", + description=( + "This agent visualizes ecommerce data, showing sales breakdowns, YOY" + " revenue performance, and regional sales outliers." + ), + url=self.base_url, + version="1.0.0", + default_input_modes=RizzchartsAgent.SUPPORTED_CONTENT_TYPES, + default_output_modes=RizzchartsAgent.SUPPORTED_CONTENT_TYPES, + capabilities=AgentCapabilities( + streaming=True, + extensions=[self.schema_manager.get_agent_extension()], + ), + skills=[ + AgentSkill( + id="view_sales_by_category", + name="View Sales by Category", + description=( + "Displays a pie chart of sales broken down by product category for" + " a given time period." + ), + tags=["sales", "breakdown", "category", "pie chart", "revenue"], + examples=[ + "show my sales breakdown by product category for q3", + "What's the sales breakdown for last month?", + ], + ), + AgentSkill( + id="view_regional_outliers", + name="View Regional Sales Outliers", + description=( + "Displays a map showing regional sales outliers or store-level" + " performance." + ), + tags=["sales", "regional", "outliers", "stores", "map", "performance"], + examples=[ + "interesting. were there any outlier stores", + "show me a map of store performance", + ], + ), + ], ) - - return final_prompt diff --git a/samples/agent/adk/rizzcharts/agent_executor.py b/samples/agent/adk/rizzcharts/agent_executor.py index 05d985fc9..f859f7c9c 100644 --- a/samples/agent/adk/rizzcharts/agent_executor.py +++ b/samples/agent/adk/rizzcharts/agent_executor.py @@ -23,18 +23,8 @@ from a2ui.extension.a2ui_extension import STANDARD_CATALOG_ID from a2ui.extension.a2ui_extension import get_a2ui_agent_extension from a2ui.extension.a2ui_extension import try_activate_a2ui_extension +from a2ui.inference.schema.manager import A2uiSchemaManager from a2ui.extension.send_a2ui_to_client_toolset import convert_send_a2ui_to_client_genai_part_to_a2a_part - -try: - from .agent import A2UI_CATALOG_URI_STATE_KEY # pylint: disable=import-error - from .agent import RIZZCHARTS_CATALOG_URI # pylint: disable=import-error - from .agent import RizzchartsAgent # pylint: disable=import-error - from .component_catalog_builder import ComponentCatalogBuilder # pylint: disable=import-error -except ImportError: - from agent import A2UI_CATALOG_URI_STATE_KEY - from agent import RIZZCHARTS_CATALOG_URI - from agent import RizzchartsAgent - from component_catalog_builder import ComponentCatalogBuilder from google.adk.a2a.converters.request_converter import AgentRunRequest from google.adk.a2a.executor.a2a_agent_executor import A2aAgentExecutor from google.adk.a2a.executor.a2a_agent_executor import A2aAgentExecutorConfig @@ -47,19 +37,32 @@ logger = logging.getLogger(__name__) _A2UI_ENABLED_KEY = "system:a2ui_enabled" -_A2UI_SCHEMA_KEY = "system:a2ui_schema" +_A2UI_CATALOG_KEY = "system:a2ui_catalog" +_A2UI_EXAMPLES_KEY = "system:a2ui_examples" -def get_a2ui_schema(ctx: ReadonlyContext): - """Retrieves the A2UI schema from the session state. +def get_a2ui_catalog(ctx: ReadonlyContext): + """Retrieves the A2UI catalog from the session state. Args: - ctx: The ReadonlyContext for resolving the schema. + ctx: The ReadonlyContext for resolving the catalog. Returns: - The A2UI schema or None if not found. + The A2UI catalog or None if not found. """ - return ctx.state.get(_A2UI_SCHEMA_KEY) + return ctx.state.get(_A2UI_CATALOG_KEY) + + +def get_a2ui_examples(ctx: ReadonlyContext): + """Retrieves the A2UI examples from the session state. + + Args: + ctx: The ReadonlyContext for resolving the examples. + + Returns: + The A2UI examples or None if not found. + """ + return ctx.state.get(_A2UI_EXAMPLES_KEY) def get_a2ui_enabled(ctx: ReadonlyContext): @@ -81,79 +84,16 @@ def __init__( self, base_url: str, runner: Runner, - a2ui_schema_content: str, - standard_catalog_content: str, - rizzcharts_catalog_content: str, + schema_manager: A2uiSchemaManager, ): self._base_url = base_url - self._component_catalog_builder = ComponentCatalogBuilder( - a2ui_schema_content=a2ui_schema_content, - uri_to_local_catalog_content={ - STANDARD_CATALOG_ID: standard_catalog_content, - RIZZCHARTS_CATALOG_URI: rizzcharts_catalog_content, - }, - default_catalog_uri=STANDARD_CATALOG_ID, - ) + self.schema_manager = schema_manager config = A2aAgentExecutorConfig( gen_ai_part_converter=convert_send_a2ui_to_client_genai_part_to_a2a_part ) super().__init__(runner=runner, config=config) - def get_agent_card(self) -> AgentCard: - """Returns the AgentCard defining this agent's metadata and skills. - - Returns: - An AgentCard object. - """ - return AgentCard( - name="Ecommerce Dashboard Agent", - description=( - "This agent visualizes ecommerce data, showing sales breakdowns, YOY" - " revenue performance, and regional sales outliers." - ), - url=self._base_url, - version="1.0.0", - default_input_modes=RizzchartsAgent.SUPPORTED_CONTENT_TYPES, - default_output_modes=RizzchartsAgent.SUPPORTED_CONTENT_TYPES, - capabilities=AgentCapabilities( - streaming=True, - extensions=[ - get_a2ui_agent_extension( - supported_catalog_ids=[STANDARD_CATALOG_ID, RIZZCHARTS_CATALOG_URI] - ) - ], - ), - skills=[ - AgentSkill( - id="view_sales_by_category", - name="View Sales by Category", - description=( - "Displays a pie chart of sales broken down by product category for" - " a given time period." - ), - tags=["sales", "breakdown", "category", "pie chart", "revenue"], - examples=[ - "show my sales breakdown by product category for q3", - "What's the sales breakdown for last month?", - ], - ), - AgentSkill( - id="view_regional_outliers", - name="View Regional Sales Outliers", - description=( - "Displays a map showing regional sales outliers or store-level" - " performance." - ), - tags=["sales", "regional", "outliers", "stores", "map", "performance"], - examples=[ - "interesting. were there any outlier stores", - "show me a map of store performance", - ], - ), - ], - ) - @override async def _prepare_session( self, @@ -170,13 +110,16 @@ async def _prepare_session( use_ui = try_activate_a2ui_extension(context) if use_ui: - a2ui_schema, catalog_uri = self._component_catalog_builder.load_a2ui_schema( - client_ui_capabilities=context.message.metadata.get( - A2UI_CLIENT_CAPABILITIES_KEY - ) + capabilities = ( + context.message.metadata.get(A2UI_CLIENT_CAPABILITIES_KEY) if context.message and context.message.metadata else None ) + a2ui_catalog = self.schema_manager.get_effective_catalog( + client_ui_capabilities=capabilities + ) + + examples = self.schema_manager.load_examples(a2ui_catalog, validate=True) await runner.session_service.append_event( session, @@ -186,8 +129,8 @@ async def _prepare_session( actions=EventActions( state_delta={ _A2UI_ENABLED_KEY: True, - _A2UI_SCHEMA_KEY: a2ui_schema, - A2UI_CATALOG_URI_STATE_KEY: catalog_uri, + _A2UI_CATALOG_KEY: a2ui_catalog, + _A2UI_EXAMPLES_KEY: examples, } ), ), diff --git a/samples/agent/adk/rizzcharts/component_catalog_builder.py b/samples/agent/adk/rizzcharts/component_catalog_builder.py deleted file mode 100644 index 4b7f45dd5..000000000 --- a/samples/agent/adk/rizzcharts/component_catalog_builder.py +++ /dev/null @@ -1,103 +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. - -import json -import logging -from typing import Any, List, Optional -from a2ui.extension.a2ui_extension import INLINE_CATALOGS_KEY, SUPPORTED_CATALOG_IDS_KEY - -try: - from .agent import RIZZCHARTS_CATALOG_URI, STANDARD_CATALOG_ID -except ImportError: - from agent import RIZZCHARTS_CATALOG_URI, STANDARD_CATALOG_ID - -logger = logging.getLogger(__name__) - - -class ComponentCatalogBuilder: - - def __init__( - self, - a2ui_schema_content: str, - uri_to_local_catalog_content: dict[str, str], - default_catalog_uri: Optional[str], - ): - self._a2ui_schema_content = a2ui_schema_content - self._uri_to_local_catalog_content = uri_to_local_catalog_content - self._default_catalog_uri = default_catalog_uri - - def load_a2ui_schema( - self, client_ui_capabilities: Optional[dict[str, Any]] - ) -> tuple[dict[str, Any], Optional[str]]: - """ - Returns: - A tuple of the a2ui_schema and the catalog uri - """ - try: - logger.info(f"Loading A2UI client capabilities {client_ui_capabilities}") - - if client_ui_capabilities: - supported_catalog_uris: List[str] = client_ui_capabilities.get( - SUPPORTED_CATALOG_IDS_KEY - ) - if RIZZCHARTS_CATALOG_URI in supported_catalog_uris: - catalog_uri = RIZZCHARTS_CATALOG_URI - elif STANDARD_CATALOG_ID in supported_catalog_uris: - catalog_uri = STANDARD_CATALOG_ID - else: - catalog_uri = None - - inline_catalog_str = client_ui_capabilities.get(INLINE_CATALOGS_KEY) - elif self._default_catalog_uri: - logger.info( - f"Using default catalog {self._default_catalog_uri} since client UI" - " capabilities not found" - ) - catalog_uri = self._default_catalog_uri - inline_catalog_str = None - else: - raise ValueError("Client UI capabilities not provided") - - if catalog_uri and inline_catalog_str: - raise ValueError( - f"Cannot set both {SUPPORTED_CATALOG_IDS_KEY} and {INLINE_CATALOGS_KEY} in" - f" ClientUiCapabilities: {client_ui_capabilities}" - ) - elif catalog_uri: - if catalog_str := self._uri_to_local_catalog_content.get(catalog_uri): - logger.info(f"Loading local component catalog with uri {catalog_uri}") - catalog_json = json.loads(catalog_str) - else: - raise ValueError(f"Local component catalog with URI {catalog_uri} not found") - elif inline_catalog_str: - logger.info(f"Loading inline component catalog {inline_catalog_str[:200]}") - catalog_json = json.loads(inline_catalog_str) - else: - raise ValueError("No supported catalogs found in client UI capabilities") - - logger.info("Loading A2UI schema") - a2ui_schema_json = json.loads(self._a2ui_schema_content) - - a2ui_schema_json["properties"]["surfaceUpdate"]["properties"]["components"][ - "items" - ]["properties"]["component"]["properties"] = catalog_json - - return a2ui_schema_json, catalog_uri - - except Exception as e: - logger.error( - "Failed to a2ui schema with client ui capabilities" - f" {client_ui_capabilities}: {e}" - ) - raise e diff --git a/samples/agent/adk/rizzcharts/examples/rizzcharts_catalog/chart.json b/samples/agent/adk/rizzcharts/examples/rizzcharts_catalog/chart.json index b129bf33a..9fec64ba3 100644 --- a/samples/agent/adk/rizzcharts/examples/rizzcharts_catalog/chart.json +++ b/samples/agent/adk/rizzcharts/examples/rizzcharts_catalog/chart.json @@ -14,10 +14,12 @@ "component": { "Canvas": { "children": { - "explicitList": ["chart-container"] + "explicitList": [ + "chart-container" + ] } } - } + } }, { "id": "chart-container", @@ -38,10 +40,10 @@ "Chart": { "type": "doughnut", "title": { - "path": "chart.title" + "path": "/chart.title" }, "chartData": { - "path": "chart.items" + "path": "/chart.items" } } } @@ -54,41 +56,146 @@ "surfaceId": "sales-dashboard", "path": "/", "contents": [ - { "key": "chart.title", "valueString": "Sales by Category" }, - { "key": "chart.items[0].label", "valueString": "Apparel" }, - { "key": "chart.items[0].value", "valueNumber": 41 }, - { "key": "chart.items[0].drillDown[0].label", "valueString": "Tops" }, - { "key": "chart.items[0].drillDown[0].value", "valueNumber": 31 }, - { "key": "chart.items[0].drillDown[1].label", "valueString": "Bottoms" }, - { "key": "chart.items[0].drillDown[1].value", "valueNumber": 38 }, - { "key": "chart.items[0].drillDown[2].label", "valueString": "Outerwear" }, - { "key": "chart.items[0].drillDown[2].value", "valueNumber": 20 }, - { "key": "chart.items[0].drillDown[3].label", "valueString": "Footwear" }, - { "key": "chart.items[0].drillDown[3].value", "valueNumber": 11 }, - { "key": "chart.items[1].label", "valueString": "Home Goods" }, - { "key": "chart.items[1].value", "valueNumber": 15 }, - { "key": "chart.items[1].drillDown[0].label", "valueString": "Pillow" }, - { "key": "chart.items[1].drillDown[0].value", "valueNumber": 8 }, - { "key": "chart.items[1].drillDown[1].label", "valueString": "Coffee Maker" }, - { "key": "chart.items[1].drillDown[1].value", "valueNumber": 16 }, - { "key": "chart.items[1].drillDown[2].label", "valueString": "Area Rug" }, - { "key": "chart.items[1].drillDown[2].value", "valueNumber": 3 }, - { "key": "chart.items[1].drillDown[3].label", "valueString": "Bath Towels" }, - { "key": "chart.items[1].drillDown[3].value", "valueNumber": 14 }, - { "key": "chart.items[2].label", "valueString": "Electronics" }, - { "key": "chart.items[2].value", "valueNumber": 28 }, - { "key": "chart.items[2].drillDown[0].label", "valueString": "Phones" }, - { "key": "chart.items[2].drillDown[0].value", "valueNumber": 25 }, - { "key": "chart.items[2].drillDown[1].label", "valueString": "Laptops" }, - { "key": "chart.items[2].drillDown[1].value", "valueNumber": 27 }, - { "key": "chart.items[2].drillDown[2].label", "valueString": "TVs" }, - { "key": "chart.items[2].drillDown[2].value", "valueNumber": 21 }, - { "key": "chart.items[2].drillDown[3].label", "valueString": "Other" }, - { "key": "chart.items[2].drillDown[3].value", "valueNumber": 27 }, - { "key": "chart.items[3].label", "valueString": "Health & Beauty" }, - { "key": "chart.items[3].value", "valueNumber": 10 }, - { "key": "chart.items[4].label", "valueString": "Other" }, - { "key": "chart.items[4].value", "valueNumber": 6 } + { + "key": "chart.title", + "valueString": "Sales by Category" + }, + { + "key": "chart.items[0].label", + "valueString": "Apparel" + }, + { + "key": "chart.items[0].value", + "valueNumber": 41 + }, + { + "key": "chart.items[0].drillDown[0].label", + "valueString": "Tops" + }, + { + "key": "chart.items[0].drillDown[0].value", + "valueNumber": 31 + }, + { + "key": "chart.items[0].drillDown[1].label", + "valueString": "Bottoms" + }, + { + "key": "chart.items[0].drillDown[1].value", + "valueNumber": 38 + }, + { + "key": "chart.items[0].drillDown[2].label", + "valueString": "Outerwear" + }, + { + "key": "chart.items[0].drillDown[2].value", + "valueNumber": 20 + }, + { + "key": "chart.items[0].drillDown[3].label", + "valueString": "Footwear" + }, + { + "key": "chart.items[0].drillDown[3].value", + "valueNumber": 11 + }, + { + "key": "chart.items[1].label", + "valueString": "Home Goods" + }, + { + "key": "chart.items[1].value", + "valueNumber": 15 + }, + { + "key": "chart.items[1].drillDown[0].label", + "valueString": "Pillow" + }, + { + "key": "chart.items[1].drillDown[0].value", + "valueNumber": 8 + }, + { + "key": "chart.items[1].drillDown[1].label", + "valueString": "Coffee Maker" + }, + { + "key": "chart.items[1].drillDown[1].value", + "valueNumber": 16 + }, + { + "key": "chart.items[1].drillDown[2].label", + "valueString": "Area Rug" + }, + { + "key": "chart.items[1].drillDown[2].value", + "valueNumber": 3 + }, + { + "key": "chart.items[1].drillDown[3].label", + "valueString": "Bath Towels" + }, + { + "key": "chart.items[1].drillDown[3].value", + "valueNumber": 14 + }, + { + "key": "chart.items[2].label", + "valueString": "Electronics" + }, + { + "key": "chart.items[2].value", + "valueNumber": 28 + }, + { + "key": "chart.items[2].drillDown[0].label", + "valueString": "Phones" + }, + { + "key": "chart.items[2].drillDown[0].value", + "valueNumber": 25 + }, + { + "key": "chart.items[2].drillDown[1].label", + "valueString": "Laptops" + }, + { + "key": "chart.items[2].drillDown[1].value", + "valueNumber": 27 + }, + { + "key": "chart.items[2].drillDown[2].label", + "valueString": "TVs" + }, + { + "key": "chart.items[2].drillDown[2].value", + "valueNumber": 21 + }, + { + "key": "chart.items[2].drillDown[3].label", + "valueString": "Other" + }, + { + "key": "chart.items[2].drillDown[3].value", + "valueNumber": 27 + }, + { + "key": "chart.items[3].label", + "valueString": "Health & Beauty" + }, + { + "key": "chart.items[3].value", + "valueNumber": 10 + }, + { + "key": "chart.items[4].label", + "valueString": "Other" + }, + { + "key": "chart.items[4].value", + "valueNumber": 6 + } ] } } diff --git a/samples/agent/adk/rizzcharts/examples/rizzcharts_catalog/map.json b/samples/agent/adk/rizzcharts/examples/rizzcharts_catalog/map.json index e3c4f9596..08530a89b 100644 --- a/samples/agent/adk/rizzcharts/examples/rizzcharts_catalog/map.json +++ b/samples/agent/adk/rizzcharts/examples/rizzcharts_catalog/map.json @@ -14,10 +14,12 @@ "component": { "Canvas": { "children": { - "explicitList": ["map-layout-container"] + "explicitList": [ + "map-layout-container" + ] } } - } + } }, { "id": "map-layout-container", @@ -49,13 +51,13 @@ "component": { "GoogleMap": { "center": { - "path": "mapConfig.center" + "path": "/mapConfig.center" }, "zoom": { - "path": "mapConfig.zoom" + "path": "/mapConfig.zoom" }, "pins": { - "path": "mapConfig.locations" + "path": "/mapConfig.locations" } } } @@ -68,38 +70,107 @@ "surfaceId": "la-map-view", "path": "/", "contents": [ - { "key": "mapConfig.center.lat", "valueNumber": 34.0522 }, - { "key": "mapConfig.center.lng", "valueNumber": -118.2437 }, - { "key": "mapConfig.zoom", "valueNumber": 11 }, - - { "key": "mapConfig.locations[0].lat", "valueNumber": 34.0135 }, - { "key": "mapConfig.locations[0].lng", "valueNumber": -118.4947 }, - { "key": "mapConfig.locations[0].name", "valueString": "Google Store Santa Monica" }, - { "key": "mapConfig.locations[0].description", "valueString": "Your local destination for Google hardware." }, - { "key": "mapConfig.locations[0].background", "valueString": "#4285F4" }, - { "key": "mapConfig.locations[0].borderColor", "valueString": "#FFFFFF" }, - { "key": "mapConfig.locations[0].glyphColor", "valueString": "#FFFFFF" }, - - { "key": "mapConfig.locations[1].lat", "valueNumber": 34.1341 }, - { "key": "mapConfig.locations[1].lng", "valueNumber": -118.3215 }, - { "key": "mapConfig.locations[1].name", "valueString": "Griffith Observatory" }, - - { "key": "mapConfig.locations[2].lat", "valueNumber": 34.1340 }, - { "key": "mapConfig.locations[2].lng", "valueNumber": -118.3397 }, - { "key": "mapConfig.locations[2].name", "valueString": "Hollywood Sign Viewpoint" }, - - { "key": "mapConfig.locations[3].lat", "valueNumber": 34.0453 }, - { "key": "mapConfig.locations[3].lng", "valueNumber": -118.2673 }, - { "key": "mapConfig.locations[3].name", "valueString": "Crypto.com Arena" }, - - { "key": "mapConfig.locations[4].lat", "valueNumber": 34.0639 }, - { "key": "mapConfig.locations[4].lng", "valueNumber": -118.3592 }, - { "key": "mapConfig.locations[4].name", "valueString": "LACMA" }, - - { "key": "mapConfig.locations[5].lat", "valueNumber": 33.9850 }, - { "key": "mapConfig.locations[5].lng", "valueNumber": -118.4729 }, - { "key": "mapConfig.locations[5].name", "valueString": "Venice Beach Boardwalk" } + { + "key": "mapConfig.center.lat", + "valueNumber": 34.0522 + }, + { + "key": "mapConfig.center.lng", + "valueNumber": -118.2437 + }, + { + "key": "mapConfig.zoom", + "valueNumber": 11 + }, + { + "key": "mapConfig.locations[0].lat", + "valueNumber": 34.0135 + }, + { + "key": "mapConfig.locations[0].lng", + "valueNumber": -118.4947 + }, + { + "key": "mapConfig.locations[0].name", + "valueString": "Google Store Santa Monica" + }, + { + "key": "mapConfig.locations[0].description", + "valueString": "Your local destination for Google hardware." + }, + { + "key": "mapConfig.locations[0].background", + "valueString": "#4285F4" + }, + { + "key": "mapConfig.locations[0].borderColor", + "valueString": "#FFFFFF" + }, + { + "key": "mapConfig.locations[0].glyphColor", + "valueString": "#FFFFFF" + }, + { + "key": "mapConfig.locations[1].lat", + "valueNumber": 34.1341 + }, + { + "key": "mapConfig.locations[1].lng", + "valueNumber": -118.3215 + }, + { + "key": "mapConfig.locations[1].name", + "valueString": "Griffith Observatory" + }, + { + "key": "mapConfig.locations[2].lat", + "valueNumber": 34.1340 + }, + { + "key": "mapConfig.locations[2].lng", + "valueNumber": -118.3397 + }, + { + "key": "mapConfig.locations[2].name", + "valueString": "Hollywood Sign Viewpoint" + }, + { + "key": "mapConfig.locations[3].lat", + "valueNumber": 34.0453 + }, + { + "key": "mapConfig.locations[3].lng", + "valueNumber": -118.2673 + }, + { + "key": "mapConfig.locations[3].name", + "valueString": "Crypto.com Arena" + }, + { + "key": "mapConfig.locations[4].lat", + "valueNumber": 34.0639 + }, + { + "key": "mapConfig.locations[4].lng", + "valueNumber": -118.3592 + }, + { + "key": "mapConfig.locations[4].name", + "valueString": "LACMA" + }, + { + "key": "mapConfig.locations[5].lat", + "valueNumber": 33.9850 + }, + { + "key": "mapConfig.locations[5].lng", + "valueNumber": -118.4729 + }, + { + "key": "mapConfig.locations[5].name", + "valueString": "Venice Beach Boardwalk" + } ] } } -] +] \ No newline at end of file diff --git a/samples/agent/adk/rizzcharts/examples/standard_catalog/chart.json b/samples/agent/adk/rizzcharts/examples/standard_catalog/chart.json index bc1a464c3..76169f133 100644 --- a/samples/agent/adk/rizzcharts/examples/standard_catalog/chart.json +++ b/samples/agent/adk/rizzcharts/examples/standard_catalog/chart.json @@ -12,7 +12,7 @@ { "surfaceUpdate": { "surfaceId": "sales-dashboard", - "components": [ + "components": [ { "id": "root-column", "component": { @@ -31,7 +31,7 @@ "component": { "Text": { "text": { - "path": "chart.title" + "path": "/chart.title" }, "usageHint": "h2" } @@ -78,7 +78,7 @@ "component": { "Text": { "text": { - "path": "label" + "path": "/label" } } } @@ -88,7 +88,7 @@ "component": { "Text": { "text": { - "path": "value" + "path": "/value" } } } @@ -101,18 +101,51 @@ "surfaceId": "sales-dashboard", "path": "/", "contents": [ - { "key": "chart.title", "valueString": "Sales by Category" }, - { "key": "chart.items[0].label", "valueString": "Apparel" }, - { "key": "chart.items[0].value", "valueNumber": 41 }, - { "key": "chart.items[1].label", "valueString": "Home Goods" }, - { "key": "chart.items[1].value", "valueNumber": 15 }, - { "key": "chart.items[2].label", "valueString": "Electronics" }, - { "key": "chart.items[2].value", "valueNumber": 28 }, - { "key": "chart.items[3].label", "valueString": "Health & Beauty" }, - { "key": "chart.items[3].value", "valueNumber": 10 }, - { "key": "chart.items[4].label", "valueString": "Other" }, - { "key": "chart.items[4].value", "valueNumber": 6 } + { + "key": "chart.title", + "valueString": "Sales by Category" + }, + { + "key": "chart.items[0].label", + "valueString": "Apparel" + }, + { + "key": "chart.items[0].value", + "valueNumber": 41 + }, + { + "key": "chart.items[1].label", + "valueString": "Home Goods" + }, + { + "key": "chart.items[1].value", + "valueNumber": 15 + }, + { + "key": "chart.items[2].label", + "valueString": "Electronics" + }, + { + "key": "chart.items[2].value", + "valueNumber": 28 + }, + { + "key": "chart.items[3].label", + "valueString": "Health & Beauty" + }, + { + "key": "chart.items[3].value", + "valueNumber": 10 + }, + { + "key": "chart.items[4].label", + "valueString": "Other" + }, + { + "key": "chart.items[4].value", + "valueNumber": 6 + } ] } } -] +] \ No newline at end of file diff --git a/samples/agent/adk/rizzcharts/examples/standard_catalog/map.json b/samples/agent/adk/rizzcharts/examples/standard_catalog/map.json index f6061c5a6..f38396c00 100644 --- a/samples/agent/adk/rizzcharts/examples/standard_catalog/map.json +++ b/samples/agent/adk/rizzcharts/examples/standard_catalog/map.json @@ -3,7 +3,7 @@ "beginRendering": { "surfaceId": "la-map-view", "root": "root-column", - "styles": { + "styles": { "primaryColor": "#4285F4", "font": "Roboto" } @@ -12,7 +12,7 @@ { "surfaceUpdate": { "surfaceId": "la-map-view", - "components": [ + "components": [ { "id": "root-column", "component": { @@ -38,59 +38,62 @@ "usageHint": "h2" } } - }, - { - "id": "location-list", - "component": { - "List": { - "direction": "vertical", - "children": { - "template": { - "componentId": "location-card-template", - "dataBinding": "/mapConfig.locations" - } - } + }, + { + "id": "location-list", + "component": { + "List": { + "direction": "vertical", + "children": { + "template": { + "componentId": "location-card-template", + "dataBinding": "/mapConfig.locations" } + } } + } }, { - "id": "location-card-template", - "component": { - "Card": { - "child": "location-details" - } + "id": "location-card-template", + "component": { + "Card": { + "child": "location-details" } + } }, { - "id": "location-details", - "component": { - "Column": { - "children": { - "explicitList": ["location-name", "location-description"] - } - } + "id": "location-details", + "component": { + "Column": { + "children": { + "explicitList": [ + "location-name", + "location-description" + ] + } } + } }, { - "id": "location-name", - "component": { - "Text": { - "text": { - "path": "name" - }, - "usageHint": "h4" - } + "id": "location-name", + "component": { + "Text": { + "text": { + "path": "/name" + }, + "usageHint": "h4" } + } }, { - "id": "location-description", - "component": { - "Text": { - "text": { - "path": "description" - } - } + "id": "location-description", + "component": { + "Text": { + "text": { + "path": "/description" + } } + } } ] } @@ -100,19 +103,55 @@ "surfaceId": "la-map-view", "path": "/", "contents": [ - { "key": "mapConfig.locations[0].name", "valueString": "Google Store Santa Monica" }, - { "key": "mapConfig.locations[0].description", "valueString": "Your local destination for Google hardware." }, - { "key": "mapConfig.locations[1].name", "valueString": "Griffith Observatory" }, - { "key": "mapConfig.locations[1].description", "valueString": "A public observatory with views of the Hollywood Sign." }, - { "key": "mapConfig.locations[2].name", "valueString": "Hollywood Sign Viewpoint" }, - { "key": "mapConfig.locations[2].description", "valueString": "Iconic landmark in the Hollywood Hills." }, - { "key": "mapConfig.locations[3].name", "valueString": "Crypto.com Arena" }, - { "key": "mapConfig.locations[3].description", "valueString": "Multi-purpose sports and entertainment arena." }, - { "key": "mapConfig.locations[4].name", "valueString": "LACMA" }, - { "key": "mapConfig.locations[4].description", "valueString": "Los Angeles County Museum of Art." }, - { "key": "mapConfig.locations[5].name", "valueString": "Venice Beach Boardwalk" }, - { "key": "mapConfig.locations[5].description", "valueString": "Famous oceanfront promenade." } + { + "key": "mapConfig.locations[0].name", + "valueString": "Google Store Santa Monica" + }, + { + "key": "mapConfig.locations[0].description", + "valueString": "Your local destination for Google hardware." + }, + { + "key": "mapConfig.locations[1].name", + "valueString": "Griffith Observatory" + }, + { + "key": "mapConfig.locations[1].description", + "valueString": "A public observatory with views of the Hollywood Sign." + }, + { + "key": "mapConfig.locations[2].name", + "valueString": "Hollywood Sign Viewpoint" + }, + { + "key": "mapConfig.locations[2].description", + "valueString": "Iconic landmark in the Hollywood Hills." + }, + { + "key": "mapConfig.locations[3].name", + "valueString": "Crypto.com Arena" + }, + { + "key": "mapConfig.locations[3].description", + "valueString": "Multi-purpose sports and entertainment arena." + }, + { + "key": "mapConfig.locations[4].name", + "valueString": "LACMA" + }, + { + "key": "mapConfig.locations[4].description", + "valueString": "Los Angeles County Museum of Art." + }, + { + "key": "mapConfig.locations[5].name", + "valueString": "Venice Beach Boardwalk" + }, + { + "key": "mapConfig.locations[5].description", + "valueString": "Famous oceanfront promenade." + } ] } } -] +] \ No newline at end of file diff --git a/samples/agent/adk/rizzcharts/rizzcharts_catalog_definition.json b/samples/agent/adk/rizzcharts/rizzcharts_catalog_definition.json index 88006a6fa..61c1a795b 100644 --- a/samples/agent/adk/rizzcharts/rizzcharts_catalog_definition.json +++ b/samples/agent/adk/rizzcharts/rizzcharts_catalog_definition.json @@ -1,6 +1,7 @@ { + "catalogId": "https://github.com/google/A2UI/blob/main/samples/agent/adk/rizzcharts/rizzcharts_catalog_definition.json", "components": { - "$ref": "../../../../specification/v0_8/json/standard_catalog_definition.json#/components", + "$ref": "https://a2ui.org/specification/v0_8/standard_catalog_definition.json#/components", "Canvas": { "type": "object", "description": "Renders the UI element in a stateful panel next to the chat window.", @@ -11,12 +12,16 @@ "properties": { "explicitList": { "type": "array", - "items": { "type": "string" } + "items": { + "type": "string" + } } } } }, - "required": [ "children" ] + "required": [ + "children" + ] }, "Chart": { "type": "object", @@ -25,14 +30,21 @@ "type": { "type": "string", "description": "The type of chart to render.", - "enum": [ "doughnut", "pie" ] + "enum": [ + "doughnut", + "pie" + ] }, "title": { "type": "object", "description": "The title of the chart. Can be a literal string or a data model path.", "properties": { - "literalString": { "type": "string" }, - "path": { "type": "string" } + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } } }, "chartData": { @@ -44,29 +56,48 @@ "items": { "type": "object", "properties": { - "label": { "type": "string" }, - "value": { "type": "number" }, + "label": { + "type": "string" + }, + "value": { + "type": "number" + }, "drillDown": { "type": "array", "description": "An optional list of items for the next level of data.", "items": { "type": "object", "properties": { - "label": { "type": "string" }, - "value": { "type": "number" } + "label": { + "type": "string" + }, + "value": { + "type": "number" + } }, - "required": [ "label", "value" ] + "required": [ + "label", + "value" + ] } } }, - "required": [ "label", "value" ] + "required": [ + "label", + "value" + ] } }, - "path": { "type": "string" } + "path": { + "type": "string" + } } } }, - "required": [ "type", "chartData" ] + "required": [ + "type", + "chartData" + ] }, "GoogleMap": { "type": "object", @@ -79,20 +110,33 @@ "literalObject": { "type": "object", "properties": { - "lat": { "type": "number" }, - "lng": { "type": "number" } + "lat": { + "type": "number" + }, + "lng": { + "type": "number" + } }, - "required": [ "lat", "lng" ] + "required": [ + "lat", + "lng" + ] }, - "path": { "type": "string" } + "path": { + "type": "string" + } } }, "zoom": { "type": "object", "description": "The zoom level of the map. Can be a literal number or a data model path.", "properties": { - "literalNumber": { "type": "number" }, - "path": { "type": "string" } + "literalNumber": { + "type": "number" + }, + "path": { + "type": "string" + } } }, "pins": { @@ -104,22 +148,48 @@ "items": { "type": "object", "properties": { - "lat": { "type": "number" }, - "lng": { "type": "number" }, - "name": { "type": "string" }, - "description": { "type": "string" }, - "background": { "type": "string", "description": "Hex color code for the pin background (e.g., '#FBBC04')." }, - "borderColor": { "type": "string", "description": "Hex color code for the pin border (e.g., '#000000')." }, - "glyphColor": { "type": "string", "description": "Hex color code for the pin's glyph/icon (e.g., '#000000')." } + "lat": { + "type": "number" + }, + "lng": { + "type": "number" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "background": { + "type": "string", + "description": "Hex color code for the pin background (e.g., '#FBBC04')." + }, + "borderColor": { + "type": "string", + "description": "Hex color code for the pin border (e.g., '#000000')." + }, + "glyphColor": { + "type": "string", + "description": "Hex color code for the pin's glyph/icon (e.g., '#000000')." + } }, - "required": [ "lat", "lng", "name" ] + "required": [ + "lat", + "lng", + "name" + ] } }, - "path": { "type": "string" } + "path": { + "type": "string" + } } } }, - "required": [ "center", "zoom" ] + "required": [ + "center", + "zoom" + ] } } } \ No newline at end of file diff --git a/specification/v0_8/eval/GEMINI.md b/specification/v0_8/eval/GEMINI.md index 2f8a8294d..cd796c333 100644 --- a/specification/v0_8/eval/GEMINI.md +++ b/specification/v0_8/eval/GEMINI.md @@ -24,8 +24,8 @@ An A2UI message is a JSON object that can have a `surfaceId` and one of the foll For each component in the `components` array, the following rules apply: - **General**: - - A component must have an `id` and a `componentProperties` object. - - The `componentProperties` object must contain exactly one key, which defines the component's type (e.g., "Heading", "Text"). + - A component must have an `id` and a `component` object. + - The `component` object must contain exactly one key, which defines the component's type (e.g., "Heading", "Text"). - **Heading**: - **Required**: Must have a `text` property.