Skip to content

Commit d17e5e3

Browse files
authored
Replace A2UI_DELIMITER with XML/HTML style tag (#794)
* Replace A2UI_DELIMITER with XML/HTML style tag It also updates the parser to support multiple pairs of text parts and A2UI JSON parts, for example, ``` text part 1 <a2ui-json> [{...}, {...}] </a2ui-json> text part 2 <a2ui-json> [{...}, {...}] </a2ui-json> text part 3 <a2ui-json> [{...}, {...}] </a2ui-json> ``` - Tested: The orchestrator sample and all sub-agents are working as expected. * Address review comments
1 parent 546254e commit d17e5e3

14 files changed

Lines changed: 269 additions & 182 deletions

File tree

agent_sdks/python/agent_development.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -99,16 +99,18 @@ agent_executor = MyAgentExecutor(
9999
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.
100100

101101
```python
102-
from a2ui.core.parser import parse_response
102+
from a2ui.core.parser.parser import parse_response
103103

104104
# Get the catalog for the current request
105105
selected_catalog = schema_manager.get_selected_catalog()
106106

107-
# Parse the LLM's JSON part with simple fixers like removing trailing commas
108-
text_part, json_data = parse_response(text)
107+
# Parse the LLM's response into parts with simple fixers like removing trailing commas
108+
response_parts = parse_response(text)
109109

110-
# Validate the JSON part against the schema
111-
selected_catalog.validator.validate(json_data)
110+
for part in response_parts:
111+
if part.a2ui_json:
112+
# Validate the JSON part against the schema
113+
selected_catalog.validator.validate(part.a2ui_json)
112114
```
113115

114116
#### 4c. Stream the A2UI Payload
@@ -124,9 +126,10 @@ The most efficient way to generate structured agent output is to use the `parse_
124126

125127
```python
126128
from a2ui.a2a import parse_response_to_parts
129+
from a2ui.core.schema.constants import A2UI_OPEN_TAG, A2UI_CLOSE_TAG
127130

128131
# Inside your agent's stream method:
129-
final_response_content = f"{text_segment}\n{A2UI_DELIMITER}\n{json_payload}"
132+
final_response_content = f"{text_segment}\n{A2UI_OPEN_TAG}\n{json_payload}\n{A2UI_CLOSE_TAG}"
130133

131134
yield {
132135
"is_task_complete": True,

agent_sdks/python/src/a2ui/a2a.py

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -121,24 +121,26 @@ def parse_response_to_parts(
121121
Returns:
122122
A list of A2A Part objects (TextPart and/or DataPart).
123123
"""
124-
from a2ui.core.parser import parse_response
124+
from a2ui.core.parser.parser import parse_response
125125

126126
parts = []
127127
try:
128-
text_part, json_data = parse_response(content)
129-
130-
if text_part:
131-
parts.append(Part(root=TextPart(text=text_part.strip())))
132-
133-
if validator and json_data is not None and json_data != []:
134-
validator.validate(json_data)
135-
136-
if json_data:
137-
if isinstance(json_data, list):
138-
for message in json_data:
139-
parts.append(create_a2ui_part(message))
140-
else:
141-
parts.append(create_a2ui_part(json_data))
128+
response_parts = parse_response(content)
129+
130+
for part in response_parts:
131+
if part.text:
132+
parts.append(Part(root=TextPart(text=part.text)))
133+
134+
if part.a2ui_json:
135+
json_data = part.a2ui_json
136+
if validator:
137+
validator.validate(json_data)
138+
139+
if isinstance(json_data, list):
140+
for message in json_data:
141+
parts.append(create_a2ui_part(message))
142+
else:
143+
parts.append(create_a2ui_part(json_data))
142144

143145
except Exception as e:
144146
logger.warning(f"Failed to parse or validate A2UI response: {e}")

agent_sdks/python/src/a2ui/adk/a2a_extension/send_a2ui_to_client_toolset.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,9 +109,9 @@ async def get_examples(ctx: ReadonlyContext) -> str:
109109
create_a2ui_part,
110110
parse_response_to_parts,
111111
)
112-
from a2ui.core.parser import parse_response, parse_and_fix
112+
from a2ui.core.parser.parser import has_a2ui_parts
113+
from a2ui.core.parser.payload_fixer import parse_and_fix
113114
from a2ui.core.schema.catalog import A2uiCatalog
114-
from a2ui.core.schema.constants import A2UI_DELIMITER
115115
from google.adk.a2a.converters import part_converter
116116
from google.adk.agents.readonly_context import ReadonlyContext
117117
from google.adk.models import LlmRequest
@@ -387,7 +387,7 @@ def convert(self, part: genai_types.Part) -> list[a2a_types.Part]:
387387

388388
# 3. Handle Text-based A2UI (TextPart)
389389
if text := part.text:
390-
if A2UI_DELIMITER in text:
390+
if has_a2ui_parts(text):
391391
return parse_response_to_parts(text, validator=self._catalog.validator)
392392

393393
# 4. Default conversion for other parts

agent_sdks/python/src/a2ui/core/parser/__init__.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,3 @@
1111
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
14-
15-
from .parser import parse_response
16-
from .payload_fixer import parse_and_fix
17-
18-
__all__ = ["parse_response", "parse_and_fix"]

agent_sdks/python/src/a2ui/core/parser/parser.py

Lines changed: 75 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -12,41 +12,91 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
import json
16-
from typing import Tuple, Any
17-
from ..schema.constants import A2UI_DELIMITER
15+
import re
16+
from dataclasses import dataclass
17+
from typing import List, Optional, Any
18+
from ..schema.constants import A2UI_OPEN_TAG, A2UI_CLOSE_TAG
1819
from .payload_fixer import parse_and_fix
1920

2021

21-
def parse_response(content: str) -> Tuple[str, Any]:
22+
_A2UI_BLOCK_PATTERN = re.compile(
23+
f"{re.escape(A2UI_OPEN_TAG)}(.*?){re.escape(A2UI_CLOSE_TAG)}", re.DOTALL
24+
)
25+
26+
27+
@dataclass
28+
class ResponsePart:
29+
"""Represents a part of the LLM response.
30+
31+
Attributes:
32+
text: The conversational text part. Can be an empty string.
33+
a2ui_json: The parsed A2UI JSON data. None if this part only contains
34+
trailing text.
35+
"""
36+
37+
text: str
38+
a2ui_json: Optional[Any] = None
39+
40+
41+
def has_a2ui_parts(content: str) -> bool:
42+
"""Checks if the content has A2UI parts."""
43+
return A2UI_OPEN_TAG in content and A2UI_CLOSE_TAG in content
44+
45+
46+
def _sanitize_json_string(json_string: str) -> str:
47+
"""Sanitizes the JSON string by removing markdown code blocks."""
48+
json_string = json_string.strip()
49+
if json_string.startswith("```json"):
50+
json_string = json_string[len("```json") :]
51+
elif json_string.startswith("```"):
52+
json_string = json_string[len("```") :]
53+
if json_string.endswith("```"):
54+
json_string = json_string[: -len("```")]
55+
json_string = json_string.strip()
56+
return json_string
57+
58+
59+
def parse_response(content: str) -> List[ResponsePart]:
2260
"""
23-
Parses the LLM response into a text part and a JSON object.
61+
Parses the LLM response into a list of ResponsePart objects.
2462
25-
Args:
26-
content: The raw LLM response.
63+
Args:
64+
content: The raw LLM response.
2765
28-
Returns:
29-
A tuple of (text_part, json_object).
30-
- text_part (str): The text before the delimiter, stripped of whitespace.
31-
- json_object (Any): The parsed JSON object.
66+
Returns:
67+
A list of ResponsePart objects.
3268
3369
Raises:
34-
ValueError: If the delimiter is missing, the JSON part is empty, or the JSON
35-
part is invalid.
70+
ValueError: If no A2UI tags are found or if the JSON part is invalid.
3671
"""
37-
if A2UI_DELIMITER not in content:
38-
raise ValueError(f"Delimiter '{A2UI_DELIMITER}' not found in response.")
72+
matches = list(_A2UI_BLOCK_PATTERN.finditer(content))
73+
74+
if not matches:
75+
raise ValueError(
76+
f"A2UI tags '{A2UI_OPEN_TAG}' and '{A2UI_CLOSE_TAG}' not found in response."
77+
)
78+
79+
response_parts = []
80+
last_end = 0
81+
82+
for match in matches:
83+
start, end = match.span()
84+
# Text preceding the JSON block
85+
text_part = content[last_end:start].strip()
3986

40-
text_part, json_string = content.split(A2UI_DELIMITER, 1)
41-
text_part = text_part.strip()
87+
# The JSON content within the tags
88+
json_string = match.group(1)
89+
json_string_cleaned = _sanitize_json_string(json_string)
90+
if not json_string_cleaned:
91+
raise ValueError("A2UI JSON part is empty.")
4292

43-
# Clean the JSON string (strip whitespace and common markdown blocks)
44-
json_string_cleaned = (
45-
json_string.strip().lstrip("```json").lstrip("```").rstrip("```").strip()
46-
)
93+
json_data = parse_and_fix(json_string_cleaned)
94+
response_parts.append(ResponsePart(text=text_part, a2ui_json=json_data))
95+
last_end = end
4796

48-
if not json_string_cleaned:
49-
raise ValueError("A2UI JSON part is empty.")
97+
# Trailing text after the last JSON block
98+
trailing_text = content[last_end:].strip()
99+
if trailing_text:
100+
response_parts.append(ResponsePart(text=trailing_text, a2ui_json=None))
50101

51-
json_data = parse_and_fix(json_string_cleaned)
52-
return text_part, json_data
102+
return response_parts

agent_sdks/python/src/a2ui/core/schema/constants.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,13 @@
4646

4747
ENCODING = "utf-8"
4848

49-
A2UI_DELIMITER = "---a2ui_JSON---"
49+
A2UI_OPEN_TAG = "<a2ui-json>"
50+
A2UI_CLOSE_TAG = "</a2ui-json>"
5051

5152
DEFAULT_WORKFLOW_RULES = f"""
5253
The generated response MUST follow these rules:
53-
1. The response MUST be in two parts, separated by the delimiter: `{A2UI_DELIMITER}`.
54-
2. The first part is your conversational text response.
55-
3. The second part is a single, raw JSON object which is a list of A2UI messages.
56-
4. The JSON part MUST validate against the provided A2UI JSON SCHEMA.
54+
1. The response can contain one or more A2UI JSON blocks.
55+
2. Each A2UI JSON block MUST be wrapped in `{A2UI_OPEN_TAG}` and `{A2UI_CLOSE_TAG}` tags.
56+
3. Between or around these blocks, you can provide conversational text.
57+
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.
5758
"""

agent_sdks/python/tests/adk/a2a_extension/test_send_a2ui_to_client_toolset.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
SendA2uiToClientToolset,
2929
)
3030
from a2ui.core.schema.catalog import A2uiCatalog
31-
from a2ui.core.schema.constants import A2UI_DELIMITER
31+
from a2ui.core.schema.constants import A2UI_OPEN_TAG, A2UI_CLOSE_TAG
3232
from google.adk.agents.readonly_context import ReadonlyContext
3333
from google.adk.tools.tool_context import ToolContext
3434
from google.genai import types as genai_types
@@ -364,7 +364,7 @@ def test_converter_class_convert_text_with_a2ui():
364364
valid_a2ui = [{"type": "Text", "text": "Hello"}]
365365
catalog_mock.validator.validate.return_value = None
366366

367-
text = f"Here is the UI:{A2UI_DELIMITER}{json.dumps(valid_a2ui)}"
367+
text = f"Here is the UI:\n{A2UI_OPEN_TAG}\n{json.dumps(valid_a2ui)}\n{A2UI_CLOSE_TAG}"
368368
part = genai_types.Part(text=text)
369369

370370
a2a_parts = converter.convert(part)
@@ -383,7 +383,7 @@ def test_converter_class_convert_text_empty_leading():
383383
ui = [{"type": "Text", "text": "Top"}]
384384
catalog_mock.validator.validate.return_value = None
385385

386-
text = f"{A2UI_DELIMITER}{json.dumps(ui)}"
386+
text = f"\n{A2UI_OPEN_TAG}\n{json.dumps(ui)}\n{A2UI_CLOSE_TAG}"
387387
part = genai_types.Part(text=text)
388388
a2a_parts = converter.convert(part)
389389

@@ -399,7 +399,7 @@ def test_converter_class_convert_text_markdown_wrapped():
399399
catalog_mock.validator.validate.return_value = None
400400

401401
# Text containing JSON wrapped in markdown tags
402-
text = f"Behold:{A2UI_DELIMITER}```json\n{json.dumps(ui)}\n```"
402+
text = f"Behold:\n{A2UI_OPEN_TAG}\n```json\n{json.dumps(ui)}\n```\n{A2UI_CLOSE_TAG}"
403403
part = genai_types.Part(text=text)
404404
a2a_parts = converter.convert(part)
405405

@@ -413,7 +413,7 @@ def test_converter_class_convert_text_with_invalid_a2ui():
413413
catalog_mock = MagicMock(spec=A2uiCatalog)
414414
converter = A2uiPartConverter(catalog_mock)
415415

416-
text = f"Here is the UI:{A2UI_DELIMITER}invalid_json"
416+
text = f"Here is the UI:\n{A2UI_OPEN_TAG}\ninvalid_json\n{A2UI_CLOSE_TAG}"
417417
part = genai_types.Part(text=text)
418418

419419
a2a_parts = converter.convert(part)

0 commit comments

Comments
 (0)