Skip to content

Commit 546254e

Browse files
authored
Introduce parse_response_to_parts helper (#785)
* Address review comments * feat: Introduce parse_response_to_parts helper In the samples, agent.py parses the LLM response and extracts the text part and A2UI JSON part. It also validates the JSON part. Then it sends the original LLM response back to agent_executor.py, which parses again. This commit consolidates the logic to only parse and validate once by leveraging a parse_response_to_parts helper. - tested: the orchestrator sample and all sub-agents worked from end-to-end. * Address review comments
1 parent b904548 commit 546254e

12 files changed

Lines changed: 219 additions & 270 deletions

File tree

agent_sdks/python/agent_development.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,23 @@ After parsing and validating the A2UI JSON payloads, wrap them in an A2A DataPar
116116

117117
To ensure the A2UI Renderers on the frontend recognize the data, add `{"mimeType": "application/json+a2ui"}` to the DataPart's metadata.
118118

119-
**Recommendation:** Use the [create_a2ui_datapart](src/a2ui/a2a.py#L37-L54) helper method to convert A2UI JSON payloads into an A2A DataPart.
119+
**Recommendation:** Use the [create_a2ui_part](src/a2ui/a2a.py) helper method to convert A2UI JSON payloads into an A2A DataPart.
120+
121+
#### 4d. Complete Agent Output Structure
122+
123+
The most efficient way to generate structured agent output is to use the `parse_response_to_parts` helper. It handles splitting the text, extracting A2UI JSON, optional validation, and wrapping everything into A2A `Part` objects.
124+
125+
```python
126+
from a2ui.a2a import parse_response_to_parts
127+
128+
# Inside your agent's stream method:
129+
final_response_content = f"{text_segment}\n{A2UI_DELIMITER}\n{json_payload}"
130+
131+
yield {
132+
"is_task_complete": True,
133+
"parts": parse_response_to_parts(final_response_content, fallback_text="OK."),
134+
}
135+
```
120136

121137
## Use Cases
122138

agent_sdks/python/src/a2ui/a2a.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from typing import Any, Optional, List
1717

1818
from a2a.server.agent_execution import RequestContext
19-
from a2a.types import AgentExtension, Part, DataPart
19+
from a2a.types import AgentExtension, Part, DataPart, TextPart
2020

2121
logger = logging.getLogger(__name__)
2222

@@ -106,6 +106,49 @@ def get_a2ui_agent_extension(
106106
)
107107

108108

109+
def parse_response_to_parts(
110+
content: str,
111+
validator: Optional[Any] = None,
112+
fallback_text: Optional[str] = None,
113+
) -> List[Part]:
114+
"""Helper to parse LLM response content into A2A Parts, with optional validation.
115+
116+
Args:
117+
content: The LLM response content, potentially containing A2UI delimiters.
118+
validator: Optional validator to run against extracted JSON payloads.
119+
fallback_text: Optional text to return if no parts are successfully created.
120+
121+
Returns:
122+
A list of A2A Part objects (TextPart and/or DataPart).
123+
"""
124+
from a2ui.core.parser import parse_response
125+
126+
parts = []
127+
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))
142+
143+
except Exception as e:
144+
logger.warning(f"Failed to parse or validate A2UI response: {e}")
145+
146+
if not parts and fallback_text:
147+
parts.append(Part(root=TextPart(text=fallback_text)))
148+
149+
return parts
150+
151+
109152
def try_activate_a2ui_extension(context: RequestContext) -> bool:
110153
"""Activates the A2UI extension if requested.
111154

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

Lines changed: 6 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,11 @@ async def get_examples(ctx: ReadonlyContext) -> str:
104104
import jsonschema
105105

106106
from a2a import types as a2a_types
107-
from ...a2a import create_a2ui_part
107+
from a2ui.a2a import (
108+
A2UI_EXTENSION_URI,
109+
create_a2ui_part,
110+
parse_response_to_parts,
111+
)
108112
from a2ui.core.parser import parse_response, parse_and_fix
109113
from a2ui.core.schema.catalog import A2uiCatalog
110114
from a2ui.core.schema.constants import A2UI_DELIMITER
@@ -384,42 +388,12 @@ def convert(self, part: genai_types.Part) -> list[a2a_types.Part]:
384388
# 3. Handle Text-based A2UI (TextPart)
385389
if text := part.text:
386390
if A2UI_DELIMITER in text:
387-
return self._convert_text_with_a2ui(text)
391+
return parse_response_to_parts(text, validator=self._catalog.validator)
388392

389393
# 4. Default conversion for other parts
390394
converted_part = part_converter.convert_genai_part_to_a2a_part(part)
391395
return [converted_part] if converted_part else []
392396

393-
def _convert_text_with_a2ui(self, text: str) -> list[a2a_types.Part]:
394-
"""Helper to split text and extract/validate A2UI JSON."""
395-
parts = []
396-
try:
397-
text_part, json_data = parse_response(text)
398-
self._catalog.validator.validate(json_data)
399-
400-
if text_part:
401-
parts.append(
402-
a2a_types.Part(root=a2a_types.TextPart(kind="text", text=text_part))
403-
)
404-
405-
logger.info(f"Found {len(json_data)} messages. Creating individual DataParts.")
406-
for message in json_data:
407-
parts.append(create_a2ui_part(message))
408-
409-
except Exception as e:
410-
logger.error(f"Failed to parse or validate text-based A2UI JSON: {e}")
411-
# Fallback: at least try to return the leading text part if we can split it
412-
if not parts:
413-
segments = text.split(A2UI_DELIMITER, 1)
414-
if segments[0].strip():
415-
parts.append(
416-
a2a_types.Part(
417-
root=a2a_types.TextPart(kind="text", text=segments[0].strip())
418-
)
419-
)
420-
421-
return parts
422-
423397

424398
@experimental
425399
class A2uiEventConverter:

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

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -376,24 +376,6 @@ def test_converter_class_convert_text_with_a2ui():
376376
catalog_mock.validator.validate.assert_called_once_with(valid_a2ui)
377377

378378

379-
def test_converter_class_convert_text_multiple_segments():
380-
catalog_mock = MagicMock(spec=A2uiCatalog)
381-
converter = A2uiPartConverter(catalog_mock)
382-
383-
ui1 = [{"type": "Text", "text": "one"}]
384-
ui2 = [{"type": "Text", "text": "two"}]
385-
386-
text = f"Intro{A2UI_DELIMITER}{json.dumps(ui1)}{A2UI_DELIMITER}Middle{A2UI_DELIMITER}{json.dumps(ui2)}"
387-
388-
part = genai_types.Part(text=text)
389-
a2a_parts = converter.convert(part)
390-
391-
# parse_response with single segment assumption will fail to parse the whole trailing string as JSON.
392-
# But our robust _convert_text_with_a2ui will still return the leading text part.
393-
assert len(a2a_parts) == 1
394-
assert a2a_parts[0].root.text == "Intro"
395-
396-
397379
def test_converter_class_convert_text_empty_leading():
398380
catalog_mock = MagicMock(spec=A2uiCatalog)
399381
converter = A2uiPartConverter(catalog_mock)
@@ -435,10 +417,7 @@ def test_converter_class_convert_text_with_invalid_a2ui():
435417
part = genai_types.Part(text=text)
436418

437419
a2a_parts = converter.convert(part)
438-
439-
# Expect only 1 part: the leading TextPart. The invalid A2UI is skipped.
440-
assert len(a2a_parts) == 1
441-
assert a2a_parts[0].root.text == "Here is the UI:"
420+
assert len(a2a_parts) == 0
442421

443422

444423
def test_converter_class_convert_other_part():

samples/agent/adk/component_gallery/agent.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
"""Agent logic for the Component Gallery."""
22

33
import logging
4-
import json
54
from collections.abc import AsyncIterable
65
from typing import Any
6+
import json
7+
8+
from a2a.types import DataPart, Part, TextPart
9+
from a2ui.core.schema.constants import A2UI_DELIMITER
10+
from a2ui.a2a import create_a2ui_part, parse_response_to_parts
711

812
import asyncio
913
import datetime
@@ -29,10 +33,9 @@ async def stream(self, query: str, session_id: str) -> AsyncIterable[dict[str, A
2933
gallery_json = get_gallery_json()
3034
yield {
3135
"is_task_complete": True,
32-
"payload": {
33-
"text": "Here is the component gallery.",
34-
"json_string": gallery_json,
35-
},
36+
"parts": parse_response_to_parts(
37+
f"Here is the component gallery.\n{A2UI_DELIMITER}\n{gallery_json}"
38+
),
3639
}
3740
return
3841

@@ -46,7 +49,7 @@ async def stream(self, query: str, session_id: str) -> AsyncIterable[dict[str, A
4649

4750
timestamp = datetime.datetime.now().strftime("%H:%M:%S")
4851

49-
response_update = [{
52+
response_update = {
5053
"surfaceUpdate": {
5154
"surfaceId": "response-surface",
5255
"components": [{
@@ -63,16 +66,19 @@ async def stream(self, query: str, session_id: str) -> AsyncIterable[dict[str, A
6366
},
6467
}],
6568
}
66-
}]
69+
}
6770

6871
yield {
6972
"is_task_complete": True,
70-
"payload": {"text": "Action processed.", "json_data": response_update},
73+
"parts": [
74+
Part(root=TextPart(text="Action processed.")),
75+
create_a2ui_part(response_update),
76+
],
7177
}
7278
return
7379

7480
# Fallback for text
7581
yield {
7682
"is_task_complete": True,
77-
"payload": {"text": "I am the Component Gallery Agent."},
83+
"parts": [Part(root=TextPart(text="I am the Component Gallery Agent."))],
7884
}

samples/agent/adk/component_gallery/agent_executor.py

Lines changed: 2 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,7 @@
88
from a2a.types import (DataPart, Part, TaskState, TextPart)
99
from a2a.utils import new_agent_parts_message, new_task
1010
from agent import ComponentGalleryAgent
11-
from a2ui.a2a import create_a2ui_part, try_activate_a2ui_extension
12-
from a2ui.core.parser import parse_response
11+
from a2ui.a2a import try_activate_a2ui_extension
1312

1413
logger = logging.getLogger(__name__)
1514

@@ -50,46 +49,7 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non
5049
updater = TaskUpdater(event_queue, task.id, task.context_id)
5150

5251
async for item in self.agent.stream(query, task.context_id):
53-
final_parts = []
54-
55-
if "payload" in item:
56-
payload = item["payload"]
57-
text = payload.get("text")
58-
if text:
59-
final_parts.append(Part(root=TextPart(text=text)))
60-
61-
json_data = payload.get("json_data")
62-
json_string = payload.get("json_string")
63-
64-
if json_string:
65-
try:
66-
json_data = json.loads(json_string)
67-
except Exception as e:
68-
logger.error(f"Failed to parse JSON string: {e}")
69-
70-
if json_data:
71-
if isinstance(json_data, list):
72-
for msg in json_data:
73-
final_parts.append(create_a2ui_part(msg))
74-
else:
75-
final_parts.append(create_a2ui_part(json_data))
76-
else:
77-
content = item.get("content", "")
78-
try:
79-
text_part, json_data = parse_response(content)
80-
if text_part.strip():
81-
final_parts.append(Part(root=TextPart(text=text_part.strip())))
82-
83-
if json_data:
84-
if isinstance(json_data, list):
85-
for msg in json_data:
86-
final_parts.append(create_a2ui_part(msg))
87-
else:
88-
final_parts.append(create_a2ui_part(json_data))
89-
except (ValueError, json.JSONDecodeError) as e:
90-
logger.error(f"Failed to parse response: {content}, {e}")
91-
if content:
92-
final_parts.append(Part(root=TextPart(text=content)))
52+
final_parts = item["parts"]
9353

9454
await updater.update_status(
9555
TaskState.completed,

0 commit comments

Comments
 (0)