Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions agent_sdks/python/agent_development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
32 changes: 17 additions & 15 deletions agent_sdks/python/src/a2ui/a2a.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 0 additions & 5 deletions agent_sdks/python/src/a2ui/core/parser/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
100 changes: 75 additions & 25 deletions agent_sdks/python/src/a2ui/core/parser/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
11 changes: 6 additions & 5 deletions agent_sdks/python/src/a2ui/core/schema/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,13 @@

ENCODING = "utf-8"

A2UI_DELIMITER = "---a2ui_JSON---"
A2UI_OPEN_TAG = "<a2ui-json>"
A2UI_CLOSE_TAG = "</a2ui-json>"

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.
"""
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)

Expand All @@ -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)

Expand All @@ -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)
Expand Down
Loading
Loading