diff --git a/agent_sdks/python/agent_development.md b/agent_sdks/python/agent_development.md index 467e9fd4b..502dd66c7 100644 --- a/agent_sdks/python/agent_development.md +++ b/agent_sdks/python/agent_development.md @@ -99,16 +99,18 @@ agent_executor = MyAgentExecutor( To ensure reliability, always validate the LLM's JSON output before returning it. The SDK's `A2uiCatalog` provides a validator that checks the payload against the A2UI schema. If the payload is invalid, the validator will attempt to fix it. ```python -from a2ui.core.parser import parse_response +from a2ui.core.parser.parser import parse_response # Get the catalog for the current request selected_catalog = schema_manager.get_selected_catalog() -# Parse the LLM's JSON part with simple fixers like removing trailing commas -text_part, json_data = parse_response(text) +# Parse the LLM's response into parts with simple fixers like removing trailing commas +response_parts = parse_response(text) -# Validate the JSON part against the schema -selected_catalog.validator.validate(json_data) +for part in response_parts: + if part.a2ui_json: + # Validate the JSON part against the schema + selected_catalog.validator.validate(part.a2ui_json) ``` #### 4c. Stream the A2UI Payload @@ -124,9 +126,10 @@ The most efficient way to generate structured agent output is to use the `parse_ ```python from a2ui.a2a import parse_response_to_parts +from a2ui.core.schema.constants import A2UI_OPEN_TAG, A2UI_CLOSE_TAG # Inside your agent's stream method: -final_response_content = f"{text_segment}\n{A2UI_DELIMITER}\n{json_payload}" +final_response_content = f"{text_segment}\n{A2UI_OPEN_TAG}\n{json_payload}\n{A2UI_CLOSE_TAG}" yield { "is_task_complete": True, diff --git a/agent_sdks/python/src/a2ui/a2a.py b/agent_sdks/python/src/a2ui/a2a.py index 03dbfda45..9c5e25cf7 100644 --- a/agent_sdks/python/src/a2ui/a2a.py +++ b/agent_sdks/python/src/a2ui/a2a.py @@ -121,24 +121,26 @@ def parse_response_to_parts( Returns: A list of A2A Part objects (TextPart and/or DataPart). """ - from a2ui.core.parser import parse_response + from a2ui.core.parser.parser import parse_response parts = [] try: - text_part, json_data = parse_response(content) - - if text_part: - parts.append(Part(root=TextPart(text=text_part.strip()))) - - if validator and json_data is not None and json_data != []: - validator.validate(json_data) - - if json_data: - if isinstance(json_data, list): - for message in json_data: - parts.append(create_a2ui_part(message)) - else: - parts.append(create_a2ui_part(json_data)) + response_parts = parse_response(content) + + for part in response_parts: + if part.text: + parts.append(Part(root=TextPart(text=part.text))) + + if part.a2ui_json: + json_data = part.a2ui_json + if validator: + validator.validate(json_data) + + if isinstance(json_data, list): + for message in json_data: + parts.append(create_a2ui_part(message)) + else: + parts.append(create_a2ui_part(json_data)) except Exception as e: logger.warning(f"Failed to parse or validate A2UI response: {e}") diff --git a/agent_sdks/python/src/a2ui/adk/a2a_extension/send_a2ui_to_client_toolset.py b/agent_sdks/python/src/a2ui/adk/a2a_extension/send_a2ui_to_client_toolset.py index 5759367e9..ec79de9ac 100644 --- a/agent_sdks/python/src/a2ui/adk/a2a_extension/send_a2ui_to_client_toolset.py +++ b/agent_sdks/python/src/a2ui/adk/a2a_extension/send_a2ui_to_client_toolset.py @@ -109,9 +109,9 @@ async def get_examples(ctx: ReadonlyContext) -> str: create_a2ui_part, parse_response_to_parts, ) -from a2ui.core.parser import parse_response, parse_and_fix +from a2ui.core.parser.parser import has_a2ui_parts +from a2ui.core.parser.payload_fixer import parse_and_fix from a2ui.core.schema.catalog import A2uiCatalog -from a2ui.core.schema.constants import A2UI_DELIMITER from google.adk.a2a.converters import part_converter from google.adk.agents.readonly_context import ReadonlyContext from google.adk.models import LlmRequest @@ -387,7 +387,7 @@ def convert(self, part: genai_types.Part) -> list[a2a_types.Part]: # 3. Handle Text-based A2UI (TextPart) if text := part.text: - if A2UI_DELIMITER in text: + if has_a2ui_parts(text): return parse_response_to_parts(text, validator=self._catalog.validator) # 4. Default conversion for other parts diff --git a/agent_sdks/python/src/a2ui/core/parser/__init__.py b/agent_sdks/python/src/a2ui/core/parser/__init__.py index 908814f7f..4df27a209 100644 --- a/agent_sdks/python/src/a2ui/core/parser/__init__.py +++ b/agent_sdks/python/src/a2ui/core/parser/__init__.py @@ -11,8 +11,3 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. - -from .parser import parse_response -from .payload_fixer import parse_and_fix - -__all__ = ["parse_response", "parse_and_fix"] diff --git a/agent_sdks/python/src/a2ui/core/parser/parser.py b/agent_sdks/python/src/a2ui/core/parser/parser.py index f2e8bd013..6445d1ad3 100644 --- a/agent_sdks/python/src/a2ui/core/parser/parser.py +++ b/agent_sdks/python/src/a2ui/core/parser/parser.py @@ -12,41 +12,91 @@ # See the License for the specific language governing permissions and # limitations under the License. -import json -from typing import Tuple, Any -from ..schema.constants import A2UI_DELIMITER +import re +from dataclasses import dataclass +from typing import List, Optional, Any +from ..schema.constants import A2UI_OPEN_TAG, A2UI_CLOSE_TAG from .payload_fixer import parse_and_fix -def parse_response(content: str) -> Tuple[str, Any]: +_A2UI_BLOCK_PATTERN = re.compile( + f"{re.escape(A2UI_OPEN_TAG)}(.*?){re.escape(A2UI_CLOSE_TAG)}", re.DOTALL +) + + +@dataclass +class ResponsePart: + """Represents a part of the LLM response. + + Attributes: + text: The conversational text part. Can be an empty string. + a2ui_json: The parsed A2UI JSON data. None if this part only contains + trailing text. + """ + + text: str + a2ui_json: Optional[Any] = None + + +def has_a2ui_parts(content: str) -> bool: + """Checks if the content has A2UI parts.""" + return A2UI_OPEN_TAG in content and A2UI_CLOSE_TAG in content + + +def _sanitize_json_string(json_string: str) -> str: + """Sanitizes the JSON string by removing markdown code blocks.""" + json_string = json_string.strip() + if json_string.startswith("```json"): + json_string = json_string[len("```json") :] + elif json_string.startswith("```"): + json_string = json_string[len("```") :] + if json_string.endswith("```"): + json_string = json_string[: -len("```")] + json_string = json_string.strip() + return json_string + + +def parse_response(content: str) -> List[ResponsePart]: """ - Parses the LLM response into a text part and a JSON object. + Parses the LLM response into a list of ResponsePart objects. - Args: - content: The raw LLM response. + Args: + content: The raw LLM response. - Returns: - A tuple of (text_part, json_object). - - text_part (str): The text before the delimiter, stripped of whitespace. - - json_object (Any): The parsed JSON object. + Returns: + A list of ResponsePart objects. Raises: - ValueError: If the delimiter is missing, the JSON part is empty, or the JSON - part is invalid. + ValueError: If no A2UI tags are found or if the JSON part is invalid. """ - if A2UI_DELIMITER not in content: - raise ValueError(f"Delimiter '{A2UI_DELIMITER}' not found in response.") + matches = list(_A2UI_BLOCK_PATTERN.finditer(content)) + + if not matches: + raise ValueError( + f"A2UI tags '{A2UI_OPEN_TAG}' and '{A2UI_CLOSE_TAG}' not found in response." + ) + + response_parts = [] + last_end = 0 + + for match in matches: + start, end = match.span() + # Text preceding the JSON block + text_part = content[last_end:start].strip() - text_part, json_string = content.split(A2UI_DELIMITER, 1) - text_part = text_part.strip() + # The JSON content within the tags + json_string = match.group(1) + json_string_cleaned = _sanitize_json_string(json_string) + if not json_string_cleaned: + raise ValueError("A2UI JSON part is empty.") - # Clean the JSON string (strip whitespace and common markdown blocks) - json_string_cleaned = ( - json_string.strip().lstrip("```json").lstrip("```").rstrip("```").strip() - ) + json_data = parse_and_fix(json_string_cleaned) + response_parts.append(ResponsePart(text=text_part, a2ui_json=json_data)) + last_end = end - if not json_string_cleaned: - raise ValueError("A2UI JSON part is empty.") + # Trailing text after the last JSON block + trailing_text = content[last_end:].strip() + if trailing_text: + response_parts.append(ResponsePart(text=trailing_text, a2ui_json=None)) - json_data = parse_and_fix(json_string_cleaned) - return text_part, json_data + return response_parts diff --git a/agent_sdks/python/src/a2ui/core/schema/constants.py b/agent_sdks/python/src/a2ui/core/schema/constants.py index 315f1c9a6..62e545878 100644 --- a/agent_sdks/python/src/a2ui/core/schema/constants.py +++ b/agent_sdks/python/src/a2ui/core/schema/constants.py @@ -46,12 +46,13 @@ ENCODING = "utf-8" -A2UI_DELIMITER = "---a2ui_JSON---" +A2UI_OPEN_TAG = "" +A2UI_CLOSE_TAG = "" DEFAULT_WORKFLOW_RULES = f""" The generated response MUST follow these rules: -1. The response MUST be in two parts, separated by the delimiter: `{A2UI_DELIMITER}`. -2. The first part is your conversational text response. -3. The second part is a single, raw JSON object which is a list of A2UI messages. -4. The JSON part MUST validate against the provided A2UI JSON SCHEMA. +1. The response can contain one or more A2UI JSON blocks. +2. Each A2UI JSON block MUST be wrapped in `{A2UI_OPEN_TAG}` and `{A2UI_CLOSE_TAG}` tags. +3. Between or around these blocks, you can provide conversational text. +4. The JSON part MUST be a single, raw JSON object (usually a list of A2UI messages) and MUST validate against the provided A2UI JSON SCHEMA. """ diff --git a/agent_sdks/python/tests/adk/a2a_extension/test_send_a2ui_to_client_toolset.py b/agent_sdks/python/tests/adk/a2a_extension/test_send_a2ui_to_client_toolset.py index f78c4a10d..acfc1f0d9 100644 --- a/agent_sdks/python/tests/adk/a2a_extension/test_send_a2ui_to_client_toolset.py +++ b/agent_sdks/python/tests/adk/a2a_extension/test_send_a2ui_to_client_toolset.py @@ -28,7 +28,7 @@ SendA2uiToClientToolset, ) from a2ui.core.schema.catalog import A2uiCatalog -from a2ui.core.schema.constants import A2UI_DELIMITER +from a2ui.core.schema.constants import A2UI_OPEN_TAG, A2UI_CLOSE_TAG from google.adk.agents.readonly_context import ReadonlyContext from google.adk.tools.tool_context import ToolContext from google.genai import types as genai_types @@ -364,7 +364,7 @@ def test_converter_class_convert_text_with_a2ui(): valid_a2ui = [{"type": "Text", "text": "Hello"}] catalog_mock.validator.validate.return_value = None - text = f"Here is the UI:{A2UI_DELIMITER}{json.dumps(valid_a2ui)}" + text = f"Here is the UI:\n{A2UI_OPEN_TAG}\n{json.dumps(valid_a2ui)}\n{A2UI_CLOSE_TAG}" part = genai_types.Part(text=text) a2a_parts = converter.convert(part) @@ -383,7 +383,7 @@ def test_converter_class_convert_text_empty_leading(): ui = [{"type": "Text", "text": "Top"}] catalog_mock.validator.validate.return_value = None - text = f"{A2UI_DELIMITER}{json.dumps(ui)}" + text = f"\n{A2UI_OPEN_TAG}\n{json.dumps(ui)}\n{A2UI_CLOSE_TAG}" part = genai_types.Part(text=text) a2a_parts = converter.convert(part) @@ -399,7 +399,7 @@ def test_converter_class_convert_text_markdown_wrapped(): catalog_mock.validator.validate.return_value = None # Text containing JSON wrapped in markdown tags - text = f"Behold:{A2UI_DELIMITER}```json\n{json.dumps(ui)}\n```" + text = f"Behold:\n{A2UI_OPEN_TAG}\n```json\n{json.dumps(ui)}\n```\n{A2UI_CLOSE_TAG}" part = genai_types.Part(text=text) a2a_parts = converter.convert(part) @@ -413,7 +413,7 @@ def test_converter_class_convert_text_with_invalid_a2ui(): catalog_mock = MagicMock(spec=A2uiCatalog) converter = A2uiPartConverter(catalog_mock) - text = f"Here is the UI:{A2UI_DELIMITER}invalid_json" + text = f"Here is the UI:\n{A2UI_OPEN_TAG}\ninvalid_json\n{A2UI_CLOSE_TAG}" part = genai_types.Part(text=text) a2a_parts = converter.convert(part) diff --git a/agent_sdks/python/tests/core/parser/test_parser.py b/agent_sdks/python/tests/core/parser/test_parser.py index 6aab3c4a5..3ba6d0762 100644 --- a/agent_sdks/python/tests/core/parser/test_parser.py +++ b/agent_sdks/python/tests/core/parser/test_parser.py @@ -13,8 +13,8 @@ # limitations under the License. import pytest -from a2ui.core.parser import parse_response -from a2ui.core.schema.constants import A2UI_DELIMITER +from a2ui.core.parser.parser import parse_response, ResponsePart +from a2ui.core.schema.constants import A2UI_OPEN_TAG, A2UI_CLOSE_TAG def test_parse_empty_response(): @@ -23,67 +23,82 @@ def test_parse_empty_response(): parse_response(content) -def test_parse_response_only_text_no_delimiter(): - content = "Only text, no delimiter." +def test_parse_response_only_text_no_tags(): + content = "Only text, no tags." with pytest.raises(ValueError, match="not found in response"): parse_response(content) -def test_parse_response_only_text_with_delimiter(): - content = f"Only text, no delimiter. {A2UI_DELIMITER}```json```" +def test_parse_response_empty_tags(): + content = f"{A2UI_OPEN_TAG}{A2UI_CLOSE_TAG}" with pytest.raises(ValueError, match="A2UI JSON part is empty"): parse_response(content) -def test_parse_response_only_json_no_delimiter(): - content = '[{"id": "test"}]' - with pytest.raises(ValueError, match="not found in response"): - parse_response(content) +def test_parse_response_only_json_with_tags(): + content = f'{A2UI_OPEN_TAG}\n[{{"id": "test"}}]\n{A2UI_CLOSE_TAG}' + parts = parse_response(content) + assert len(parts) == 1 + assert parts[0].text == "" + assert parts[0].a2ui_json == [{"id": "test"}] -def test_parse_response_only_json_with_delimiter(): - content = f'{A2UI_DELIMITER} [{{"id": "test"}}]' - text, json_obj = parse_response(content) - assert text == "" - assert json_obj == [{"id": "test"}] +def test_parse_response_with_text_and_tags(): + content = f'Hello\n{A2UI_OPEN_TAG}\n[{{"id": "test"}}]\n{A2UI_CLOSE_TAG}' + parts = parse_response(content) + assert len(parts) == 1 + assert parts[0].text == "Hello" + assert parts[0].a2ui_json == [{"id": "test"}] -def test_parse_response_empty_json_list_with_delimiter(): - content = f"{A2UI_DELIMITER} [ ]" - text, json_obj = parse_response(content) - assert text == "" - assert json_obj == [] +def test_parse_response_with_trailing_text(): + content = f'Hello\n{A2UI_OPEN_TAG}\n[{{"id": "test"}}]\n{A2UI_CLOSE_TAG}\nGoodbye' + parts = parse_response(content) + assert len(parts) == 2 + assert parts[0].text == "Hello" + assert parts[0].a2ui_json == [{"id": "test"}] + assert parts[1].text == "Goodbye" + assert parts[1].a2ui_json is None -def test_parse_response_empty_json_object_with_delimiter(): - content = f"{A2UI_DELIMITER} {{ }}" - text, json_obj = parse_response(content) - assert text == "" - assert json_obj == [{}] +def test_parse_response_multiple_blocks(): + content = """ +Part 1 + +[{"id": "1"}] + +Part 2 + +[{"id": "2"}] + +Part 3 + """ + parts = parse_response(content) + assert len(parts) == 3 + assert parts[0].text == "Part 1" + assert parts[0].a2ui_json == [{"id": "1"}] -def test_parse_response_with_markdown_blocks(): - content = f'Text {A2UI_DELIMITER} ```json\n[{{"id": "test"}}]\n```' - text, json_obj = parse_response(content) - assert text == "Text" - assert json_obj == [{"id": "test"}] + assert parts[1].text == "Part 2" + assert parts[1].a2ui_json == [{"id": "2"}] + assert parts[2].text == "Part 3" + assert parts[2].a2ui_json is None -def test_parse_response_with_markdown_blocks_no_json_language(): - content = f'Text {A2UI_DELIMITER} ```\n[{{"id": "test"}}]\n```' - text, json_obj = parse_response(content) - assert text == "Text" - assert json_obj == [{"id": "test"}] - -def test_parse_response_no_markdown_blocks(): - content = f'Text {A2UI_DELIMITER} [{{"id": "test"}}]' - text, json_obj = parse_response(content) - assert text == "Text" - assert json_obj == [{"id": "test"}] +def test_parse_response_with_markdown_blocks(): + content = ( + f"Text\n{A2UI_OPEN_TAG}\n```json\n" + f'[{{"id": "test"}}]\n' + f"```\n{A2UI_CLOSE_TAG}" + ) + parts = parse_response(content) + assert len(parts) == 1 + assert parts[0].text == "Text" + assert parts[0].a2ui_json == [{"id": "test"}] def test_parse_response_invalid_json(): - content = f"Text {A2UI_DELIMITER} INVALID JSON" + content = f"{A2UI_OPEN_TAG}\ninvalid_json\n{A2UI_CLOSE_TAG}" with pytest.raises(ValueError): parse_response(content) diff --git a/samples/agent/adk/component_gallery/agent.py b/samples/agent/adk/component_gallery/agent.py index 036f3994e..72edbcc8b 100644 --- a/samples/agent/adk/component_gallery/agent.py +++ b/samples/agent/adk/component_gallery/agent.py @@ -6,7 +6,7 @@ import json from a2a.types import DataPart, Part, TextPart -from a2ui.core.schema.constants import A2UI_DELIMITER +from a2ui.core.schema.constants import A2UI_OPEN_TAG, A2UI_CLOSE_TAG from a2ui.a2a import create_a2ui_part, parse_response_to_parts import asyncio @@ -34,7 +34,8 @@ async def stream(self, query: str, session_id: str) -> AsyncIterable[dict[str, A yield { "is_task_complete": True, "parts": parse_response_to_parts( - f"Here is the component gallery.\n{A2UI_DELIMITER}\n{gallery_json}" + "Here is the component" + f" gallery.\n{A2UI_OPEN_TAG}\n{gallery_json}\n{A2UI_CLOSE_TAG}" ), } return diff --git a/samples/agent/adk/contact_lookup/agent.py b/samples/agent/adk/contact_lookup/agent.py index cab788524..2193d0c37 100644 --- a/samples/agent/adk/contact_lookup/agent.py +++ b/samples/agent/adk/contact_lookup/agent.py @@ -39,9 +39,9 @@ from google.genai import types from prompt_builder import get_text_prompt, ROLE_DESCRIPTION, WORKFLOW_DESCRIPTION, UI_DESCRIPTION from tools import get_contact_info -from a2ui.core.schema.constants import VERSION_0_8, A2UI_DELIMITER +from a2ui.core.schema.constants import VERSION_0_8, A2UI_OPEN_TAG, A2UI_CLOSE_TAG from a2ui.core.schema.manager import A2uiSchemaManager -from a2ui.core.parser import parse_response +from a2ui.core.parser.parser import parse_response, ResponsePart from a2ui.basic_catalog.provider import BasicCatalog from a2ui.a2a import create_a2ui_part, get_a2ui_agent_extension, parse_response_to_parts @@ -245,30 +245,36 @@ async def stream(self, query, session_id) -> AsyncIterable[dict[str, Any]]: f" {attempt})... ---" ) try: - text_part, parsed_json_data = parse_response(final_response_content) + response_parts = parse_response(final_response_content) - # Handle the "no results found" or empty JSON case - if parsed_json_data == []: - logger.info( - "--- ContactAgent.stream: Empty JSON list found. " - "Assuming valid (e.g., 'no results'). ---" - ) - is_valid = True - else: - # --- Validation Steps --- - # Check if it validates against the A2UI_SCHEMA - # This will raise jsonschema.exceptions.ValidationError if it fails - logger.info( - "--- ContactAgent.stream: Validating against A2UI_SCHEMA... ---" - ) - selected_catalog.validator.validate(parsed_json_data) - # --- End Validation Steps --- + for part in response_parts: + if not part.a2ui_json: + continue - logger.info( - "--- ContactAgent.stream: UI JSON successfully parsed AND validated" - f" against schema. Validation OK (Attempt {attempt}). ---" - ) - is_valid = True + parsed_json_data = part.a2ui_json + + # Handle the "no results found" or empty JSON case + if parsed_json_data == []: + logger.info( + "--- ContactAgent.stream: Empty JSON list found. " + "Assuming valid (e.g., 'no results'). ---" + ) + is_valid = True + else: + # --- Validation Steps --- + # Check if it validates against the A2UI_SCHEMA + # This will raise jsonschema.exceptions.ValidationError if it fails + logger.info( + "--- ContactAgent.stream: Validating against A2UI_SCHEMA... ---" + ) + selected_catalog.validator.validate(parsed_json_data) + # --- End Validation Steps --- + + logger.info( + "--- ContactAgent.stream: UI JSON successfully parsed AND validated" + f" against schema. Validation OK (Attempt {attempt}). ---" + ) + is_valid = True except ( ValueError, json.JSONDecodeError, @@ -311,8 +317,8 @@ async def stream(self, query, session_id) -> AsyncIterable[dict[str, Any]]: current_query_text = ( f"Your previous response was invalid. {error_message} You MUST generate a" " valid response that strictly follows the A2UI JSON SCHEMA. The response" - " MUST be a JSON list of A2UI messages. Ensure the response is split by" - f" '{A2UI_DELIMITER}' and the JSON part is well-formed. Please retry the" + " MUST be a JSON list of A2UI messages. Ensure each JSON part is wrapped in" + f" '{A2UI_OPEN_TAG}' and '{A2UI_CLOSE_TAG}' tags. Please retry the" f" original request: '{query}'" ) # Loop continues... diff --git a/samples/agent/adk/contact_lookup/prompt_builder.py b/samples/agent/adk/contact_lookup/prompt_builder.py index f16588c10..8fc94b641 100644 --- a/samples/agent/adk/contact_lookup/prompt_builder.py +++ b/samples/agent/adk/contact_lookup/prompt_builder.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from a2ui.core.schema.constants import VERSION_0_8, A2UI_DELIMITER +from a2ui.core.schema.constants import VERSION_0_8, A2UI_OPEN_TAG, A2UI_CLOSE_TAG from a2ui.core.schema.manager import A2uiSchemaManager from a2ui.basic_catalog.provider import BasicCatalog @@ -30,7 +30,7 @@ a. You MUST call the `get_contact_info` tool. b. If the tool returns a **single contact**, you MUST use the `CONTACT_CARD_EXAMPLE` template. Populate the `dataModelUpdate.contents` with the contact's details (name, title, email, etc.). c. If the tool returns **multiple contacts**, you MUST use the `CONTACT_LIST_EXAMPLE` template. Populate the `dataModelUpdate.contents` with the list of contacts for the "contacts" key. - d. If the tool returns an **empty list**, respond with text only and an empty JSON list: "I couldn't find anyone by that name.{A2UI_DELIMITER}[]" + d. If the tool returns an **empty list**, respond with text only and an empty JSON list: "I couldn't find anyone by that name.{A2UI_OPEN_TAG}[]{A2UI_CLOSE_TAG}" - **For handling a profile view (e.g., "WHO_IS: Alex Jordan..."):** a. You MUST call the `get_contact_info` tool with the specific name. diff --git a/samples/agent/adk/contact_multiple_surfaces/agent.py b/samples/agent/adk/contact_multiple_surfaces/agent.py index d1312124c..1b1d45811 100644 --- a/samples/agent/adk/contact_multiple_surfaces/agent.py +++ b/samples/agent/adk/contact_multiple_surfaces/agent.py @@ -43,9 +43,9 @@ UI_DESCRIPTION, ) from tools import get_contact_info -from a2ui.core.schema.constants import VERSION_0_8, A2UI_DELIMITER +from a2ui.core.schema.constants import VERSION_0_8, A2UI_OPEN_TAG, A2UI_CLOSE_TAG from a2ui.core.schema.manager import A2uiSchemaManager -from a2ui.core.parser import parse_response +from a2ui.core.parser.parser import parse_response, ResponsePart from a2ui.basic_catalog.provider import BasicCatalog from a2ui.core.schema.common_modifiers import remove_strict_validation from a2ui.a2a import create_a2ui_part, get_a2ui_agent_extension, parse_response_to_parts @@ -237,7 +237,8 @@ async def stream(self, query, session_id) -> AsyncIterable[dict[str, Any]]: ) final_response_content = ( - f"Message sent to {contact_name}\n{A2UI_DELIMITER}\n{json_content}" + "Message sent to {contact_name}\n" + f"{A2UI_OPEN_TAG}\n{json_content}\n{A2UI_CLOSE_TAG}" ) final_parts = parse_response_to_parts(final_response_content) @@ -260,7 +261,8 @@ async def stream(self, query, session_id) -> AsyncIterable[dict[str, Any]]: logger.info(f"--- ContactAgent.stream: Sending Floor Plan ---") final_response_content = ( - f"Here is the floor plan.\n{A2UI_DELIMITER}\n{json_content}" + "Here is the floor plan.\n" + f"{A2UI_OPEN_TAG}\n{json_content}\n{A2UI_CLOSE_TAG}" ) final_parts = parse_response_to_parts(final_response_content) @@ -320,26 +322,32 @@ async def stream(self, query, session_id) -> AsyncIterable[dict[str, Any]]: f" {attempt})... ---" ) try: - text_part, parsed_json_data = parse_response(final_response_content) - - # Handle the "no results found" or empty JSON case - if parsed_json_data == []: - logger.info( - "--- ContactAgent.stream: Empty JSON list found. " - "Assuming valid (e.g., 'no results'). ---" - ) - is_valid = True - else: - logger.info( - "--- ContactAgent.stream: Validating against A2UI_SCHEMA... ---" - ) - selected_catalog.validator.validate(parsed_json_data) - - logger.info( - "--- ContactAgent.stream: UI JSON successfully parsed AND validated" - f" against schema. Validation OK (Attempt {attempt}). ---" - ) - is_valid = True + response_parts = parse_response(final_response_content) + + for part in response_parts: + if not part.a2ui_json: + continue + + parsed_json_data = part.a2ui_json + + # Handle the "no results found" or empty JSON case + if parsed_json_data == []: + logger.info( + "--- ContactAgent.stream: Empty JSON list found. " + "Assuming valid (e.g., 'no results'). ---" + ) + is_valid = True + else: + logger.info( + "--- ContactAgent.stream: Validating against A2UI_SCHEMA... ---" + ) + selected_catalog.validator.validate(parsed_json_data) + + logger.info( + "--- ContactAgent.stream: UI JSON successfully parsed AND validated" + f" against schema. Validation OK (Attempt {attempt}). ---" + ) + is_valid = True except ( ValueError, @@ -384,8 +392,8 @@ async def stream(self, query, session_id) -> AsyncIterable[dict[str, Any]]: current_query_text = ( f"Your previous response was invalid. {error_message} You MUST generate a" " valid response that strictly follows the A2UI JSON SCHEMA. The response" - " MUST be a JSON list of A2UI messages. Ensure the response is split by" - f" '{A2UI_DELIMITER}' and the JSON part is well-formed. Please retry the" + " MUST be a JSON list of A2UI messages. Ensure each JSON part is wrapped in" + f" '{A2UI_OPEN_TAG}' and '{A2UI_CLOSE_TAG}' tags. Please retry the" f" original request: '{query}'" ) # Loop continues... diff --git a/samples/agent/adk/contact_multiple_surfaces/prompt_builder.py b/samples/agent/adk/contact_multiple_surfaces/prompt_builder.py index 3bee79cfd..8bc8cbeb0 100644 --- a/samples/agent/adk/contact_multiple_surfaces/prompt_builder.py +++ b/samples/agent/adk/contact_multiple_surfaces/prompt_builder.py @@ -14,7 +14,7 @@ import json -from a2ui.core.schema.constants import VERSION_0_8, A2UI_DELIMITER +from a2ui.core.schema.constants import VERSION_0_8, A2UI_OPEN_TAG, A2UI_CLOSE_TAG from a2ui.core.schema.manager import A2uiSchemaManager from a2ui.basic_catalog.provider import BasicCatalog from a2ui.core.schema.common_modifiers import remove_strict_validation @@ -33,7 +33,7 @@ a. You MUST call the `get_contact_info` tool. b. If the tool returns a **single contact**, you MUST use the `MULTI_SURFACE_EXAMPLE` template. Provide BOTH the Contact Card and the Org Chart in a single response. c. If the tool returns **multiple contacts**, you MUST use the `CONTACT_LIST_EXAMPLE` template. Populate the `dataModelUpdate.contents` with the list of contacts for the "contacts" key. - d. If the tool returns an **empty list**, respond with text only and an empty JSON list: "I couldn't find anyone by that name.{A2UI_DELIMITER}[]" + d. If the tool returns an **empty list**, respond with text only and an empty JSON list: "I couldn't find anyone by that name.{A2UI_OPEN_TAG}[]{A2UI_CLOSE_TAG}" - **For handling a profile view (e.g., "WHO_IS: Alex Jordan..."):** a. You MUST call the `get_contact_info` tool with the specific name. diff --git a/samples/agent/adk/restaurant_finder/agent.py b/samples/agent/adk/restaurant_finder/agent.py index 6764abd44..e5239c5fd 100644 --- a/samples/agent/adk/restaurant_finder/agent.py +++ b/samples/agent/adk/restaurant_finder/agent.py @@ -40,9 +40,9 @@ UI_DESCRIPTION, ) from tools import get_restaurants -from a2ui.core.schema.constants import VERSION_0_8, A2UI_DELIMITER +from a2ui.core.schema.constants import VERSION_0_8, A2UI_OPEN_TAG, A2UI_CLOSE_TAG from a2ui.core.schema.manager import A2uiSchemaManager -from a2ui.core.parser import parse_response +from a2ui.core.parser.parser import parse_response, ResponsePart from a2ui.basic_catalog.provider import BasicCatalog from a2ui.core.schema.common_modifiers import remove_strict_validation from a2ui.a2a import create_a2ui_part, get_a2ui_agent_extension, parse_response_to_parts @@ -241,22 +241,28 @@ async def stream(self, query, session_id) -> AsyncIterable[dict[str, Any]]: f" {attempt})... ---" ) try: - text_part, parsed_json_data = parse_response(final_response_content) + response_parts = parse_response(final_response_content) - # --- Validation Steps --- - # Check if it validates against the A2UI_SCHEMA - # This will raise jsonschema.exceptions.ValidationError if it fails - logger.info( - "--- RestaurantAgent.stream: Validating against A2UI_SCHEMA... ---" - ) - selected_catalog.validator.validate(parsed_json_data) - # --- End Validation Steps --- + for part in response_parts: + if not part.a2ui_json: + continue - logger.info( - "--- RestaurantAgent.stream: UI JSON successfully parsed AND validated" - f" against schema. Validation OK (Attempt {attempt}). ---" - ) - is_valid = True + parsed_json_data = part.a2ui_json + + # --- Validation Steps --- + # Check if it validates against the A2UI_SCHEMA + # This will raise jsonschema.exceptions.ValidationError if it fails + logger.info( + "--- RestaurantAgent.stream: Validating against A2UI_SCHEMA... ---" + ) + selected_catalog.validator.validate(parsed_json_data) + # --- End Validation Steps --- + + logger.info( + "--- RestaurantAgent.stream: UI JSON successfully parsed AND validated" + f" against schema. Validation OK (Attempt {attempt}). ---" + ) + is_valid = True except ( ValueError, @@ -300,8 +306,8 @@ async def stream(self, query, session_id) -> AsyncIterable[dict[str, Any]]: current_query_text = ( f"Your previous response was invalid. {error_message} You MUST generate a" " valid response that strictly follows the A2UI JSON SCHEMA. The response" - " MUST be a JSON list of A2UI messages. Ensure the response is split by" - f" '{A2UI_DELIMITER}' and the JSON part is well-formed. Please retry the" + " MUST be a JSON list of A2UI messages. Ensure each JSON part is wrapped in" + f" '{A2UI_OPEN_TAG}' and '{A2UI_CLOSE_TAG}' tags. Please retry the" f" original request: '{query}'" ) # Loop continues...