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...