Skip to content

Commit f50cb14

Browse files
committed
Integrate parser into send_a2ui_to_client_toolset.py
The previous commit defines a default workflow rule, which defines the LLM response with both a text part and an A2UI JSON part, separated by a delimiter. The default workflow rule is injected into the system prompt, which changes the rizzcharts response format. This commit updates the send_a2ui_to_client_toolset.py to handle the new response correctly by leveraging the parser. It also updates the payload fixer to not depend on the A2UICatalog and moves it outside of the schema package. Now the payload fixer only parses the raw JSON string and tries to fix it by removing the trailing comma. The parser can use the payload fixes to extract the JSON object.
1 parent 373af0a commit f50cb14

11 files changed

Lines changed: 472 additions & 261 deletions

File tree

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -99,18 +99,16 @@ 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
103+
102104
# Get the catalog for the current request
103105
selected_catalog = schema_manager.get_selected_catalog()
104106

105-
try:
106-
# Parse the LLM's JSON part
107-
parsed_json = json.loads(json_string)
108-
109-
# Validate and fix against the schema
110-
selected_catalog.payload_fixer.validate_and_fix(parsed_json)
111-
except Exception as e:
112-
# Handle validation errors (e.g., log error or retry with correction prompt)
113-
print(f"Validation failed: {e}")
107+
# Parse the LLM's JSON part with simple fixers like removing trailing commas
108+
text_part, json_data = parse_response(text)
109+
110+
# Validate the JSON part against the schema
111+
selected_catalog.validator.validate(json_data)
114112
```
115113

116114
#### 4c. Stream the A2UI Payload
@@ -203,8 +201,9 @@ ui_toolset = SendA2uiToClientToolset(
203201
#### 2c. Runtime Validation
204202

205203
When the LLM calls the UI tool, the toolset uses the dynamic catalog to:
206-
1. **Generate Instructions**: Automatically inject the specific schema and examples into the LLM's system prompt for that turn.
207-
2. **Validate and Fix Payloads**: Automatically validate and fix the LLM's generated JSON against the specific `A2uiCatalog` object's validator and auto-fixer.
204+
1. **Generate Instructions**: Inject the specific schema and examples into the LLM's system prompt for that turn.
205+
2. **Parse and Fix Payloads**: Parse and fix the LLM's generated JSON using the parser and payload-fixer.
206+
3. **Validate Payloads**: Validate the LLM's generated JSON against the specific `A2uiCatalog` object's validator.
208207

209208
### 3. Orchestration and Delegation
210209

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

Lines changed: 171 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -28,18 +28,20 @@
2828
that effectively sends a JSON payload to the client. This tool validates the JSON against
2929
the provided schema. It automatically wraps the provided schema in an array structure,
3030
instructing the LLM that it can send a list of UI items.
31-
* `convert_send_a2ui_to_client_genai_part_to_a2a_part`: A utility function that intercepts the `send_a2ui_json_to_client`
32-
tool calls from the LLM and converts them into `a2a_types.Part` objects, which are then
33-
returned by the A2A Agent Executor.
31+
* `A2uiEventConverter`: An event converter that automatically injects the A2UI catalog into part conversion.
3432
3533
Usage Examples:
3634
3735
1. Defining Providers:
38-
You can use simple values or callables (sync or async) for enablement and schema.
36+
You can use simple values or callables (sync or async) for enablement, catalog schema, and examples.
3937
4038
```python
4139
# Simple boolean and dict
42-
toolset = SendA2uiToClientToolset(a2ui_enabled=True, a2ui_catalog=MY_CATALOG)
40+
toolset = SendA2uiToClientToolset(
41+
a2ui_enabled=True,
42+
a2ui_catalog=MY_CATALOG,
43+
a2ui_examples=MY_EXAMPLES,
44+
)
4345
4446
# Async providers
4547
async def check_enabled(ctx: ReadonlyContext) -> bool:
@@ -48,7 +50,14 @@ async def check_enabled(ctx: ReadonlyContext) -> bool:
4850
async def get_catalog(ctx: ReadonlyContext) -> A2uiCatalog:
4951
return await fetch_catalog(ctx)
5052
51-
toolset = SendA2uiToClientToolset(a2ui_enabled=check_enabled, a2ui_catalog=get_catalog)
53+
async def get_examples(ctx: ReadonlyContext) -> str:
54+
return await fetch_examples(ctx)
55+
56+
toolset = SendA2uiToClientToolset(
57+
a2ui_enabled=check_enabled,
58+
a2ui_catalog=get_catalog,
59+
a2ui_examples=get_examples,
60+
)
5261
```
5362
5463
2. Integration with Agent:
@@ -59,10 +68,11 @@ async def get_catalog(ctx: ReadonlyContext) -> A2uiCatalog:
5968
LlmAgent(
6069
tools=[
6170
SendA2uiToClientToolset(
62-
a2ui_enabled=True,
63-
a2ui_catalog=MY_CATALOG
64-
)
65-
]
71+
a2ui_enabled=check_enabled,
72+
a2ui_catalog=get_catalog,
73+
a2ui_examples=get_examples,
74+
),
75+
],
6676
)
6777
```
6878
@@ -71,7 +81,7 @@ async def get_catalog(ctx: ReadonlyContext) -> A2uiCatalog:
7181
7282
```python
7383
config = A2aAgentExecutorConfig(
74-
genai_part_converter=convert_send_a2ui_to_client_genai_part_to_a2a_part
84+
event_converter=A2uiEventConverter()
7585
)
7686
executor = A2aAgentExecutor(config)
7787
```
@@ -81,13 +91,23 @@ async def get_catalog(ctx: ReadonlyContext) -> A2uiCatalog:
8191
import json
8292
import logging
8393
import re
84-
from typing import Any, Awaitable, Callable, Optional, TypeAlias, Union
94+
from typing import (
95+
TYPE_CHECKING,
96+
Any,
97+
Awaitable,
98+
Callable,
99+
Optional,
100+
TypeAlias,
101+
Union,
102+
)
85103

86104
import jsonschema
87105

88106
from a2a import types as a2a_types
89107
from ...a2a import create_a2ui_part
108+
from a2ui.core.parser import parse_response, parse_and_fix
90109
from a2ui.core.schema.catalog import A2uiCatalog
110+
from a2ui.core.schema.constants import A2UI_DELIMITER
91111
from google.adk.a2a.converters import part_converter
92112
from google.adk.agents.readonly_context import ReadonlyContext
93113
from google.adk.models import LlmRequest
@@ -97,6 +117,12 @@ async def get_catalog(ctx: ReadonlyContext) -> A2uiCatalog:
97117
from google.adk.utils.feature_decorator import experimental
98118
from google.genai import types as genai_types
99119

120+
if TYPE_CHECKING:
121+
from a2a.server.events import Event as A2AEvent
122+
from google.adk.a2a.converters.part_converter import GenAIPartToA2APartConverter
123+
from google.adk.agents.invocation_context import InvocationContext
124+
from google.adk.events.event import Event
125+
100126
logger = logging.getLogger(__name__)
101127

102128
A2uiEnabledProvider: TypeAlias = Callable[
@@ -163,6 +189,18 @@ async def get_tools(
163189
logger.info("A2UI is DISABLED, not adding ui tools")
164190
return []
165191

192+
async def get_part_converter(self, ctx: ReadonlyContext) -> "A2uiPartConverter":
193+
"""Returns a configured A2uiPartConverter for the given context.
194+
195+
Args:
196+
ctx: The ReadonlyContext to resolve the catalog with.
197+
198+
Returns:
199+
A configured A2uiPartConverter.
200+
"""
201+
catalog = await self._ui_tools[0]._resolve_a2ui_catalog(ctx)
202+
return A2uiPartConverter(catalog)
203+
166204
class _SendA2uiJsonToClientTool(BaseTool):
167205
TOOL_NAME = "send_a2ui_json_to_client"
168206
VALIDATED_A2UI_JSON_KEY = "validated_a2ui_json"
@@ -267,7 +305,8 @@ async def run_async(
267305
)
268306

269307
a2ui_catalog = await self._resolve_a2ui_catalog(tool_context)
270-
a2ui_json_payload = a2ui_catalog.payload_fixer.validate_and_fix(a2ui_json)
308+
a2ui_json_payload = parse_and_fix(a2ui_json)
309+
a2ui_catalog.validator.validate(a2ui_json_payload)
271310

272311
logger.info(
273312
f"Validated call to tool {self.TOOL_NAME} with {self.A2UI_JSON_ARG_NAME}"
@@ -288,49 +327,133 @@ async def run_async(
288327

289328

290329
@experimental
291-
def convert_send_a2ui_to_client_genai_part_to_a2a_part(
292-
part: genai_types.Part,
293-
) -> list[a2a_types.Part]:
294-
if (
295-
(function_response := part.function_response)
296-
and function_response.name
297-
== SendA2uiToClientToolset._SendA2uiJsonToClientTool.TOOL_NAME
298-
):
330+
class A2uiPartConverter:
331+
"""A catalog-aware GenAI to A2A part converter.
332+
333+
This converter handles both tool-based A2UI (via `send_a2ui_json_to_client`)
334+
and text-based A2UI (via A2UI delimiter tags). It uses the provided
335+
catalog to validate and fix JSON payloads.
336+
"""
337+
338+
def __init__(self, a2ui_catalog: A2uiCatalog):
339+
self._catalog = a2ui_catalog
340+
341+
def convert(self, part: genai_types.Part) -> list[a2a_types.Part]:
342+
"""Converts a GenAI part to A2A parts, with A2UI validation.
343+
344+
Args:
345+
part: The GenAI part to convert.
346+
347+
Returns:
348+
A list of A2A parts.
349+
"""
350+
# 1. Handle Tool Responses (FunctionResponse)
299351
if (
300-
SendA2uiToClientToolset._SendA2uiJsonToClientTool.TOOL_ERROR_KEY
301-
in function_response.response
352+
(function_response := part.function_response)
353+
and function_response.name
354+
== SendA2uiToClientToolset._SendA2uiJsonToClientTool.TOOL_NAME
302355
):
303-
logger.warning(
304-
"A2UI tool call failed:"
305-
f" {function_response.response[SendA2uiToClientToolset._SendA2uiJsonToClientTool.TOOL_ERROR_KEY]}"
356+
if (
357+
SendA2uiToClientToolset._SendA2uiJsonToClientTool.TOOL_ERROR_KEY
358+
in function_response.response
359+
):
360+
logger.warning(
361+
"A2UI tool call failed:"
362+
f" {function_response.response[SendA2uiToClientToolset._SendA2uiJsonToClientTool.TOOL_ERROR_KEY]}"
363+
)
364+
return []
365+
366+
# The tool returns the list of messages directly on success
367+
json_data = function_response.response.get(
368+
SendA2uiToClientToolset._SendA2uiJsonToClientTool.VALIDATED_A2UI_JSON_KEY
306369
)
307-
return []
370+
if not json_data:
371+
logger.info("No result in A2UI tool response")
372+
return []
308373

309-
# The tool returns the list of messages directly on success
310-
json_data = function_response.response.get(
311-
SendA2uiToClientToolset._SendA2uiJsonToClientTool.VALIDATED_A2UI_JSON_KEY
312-
)
313-
if not json_data:
314-
logger.info("No result in A2UI tool response")
374+
return [create_a2ui_part(message) for message in json_data]
375+
376+
# 2. Handle Tool Calls (FunctionCall) - Skip sending to client
377+
if (
378+
(function_call := part.function_call)
379+
and function_call.name
380+
== SendA2uiToClientToolset._SendA2uiJsonToClientTool.TOOL_NAME
381+
):
315382
return []
316383

317-
final_parts = []
318-
for message in json_data:
384+
# 3. Handle Text-based A2UI (TextPart)
385+
if text := part.text:
386+
if A2UI_DELIMITER in text:
387+
return self._convert_text_with_a2ui(text)
388+
389+
# 4. Default conversion for other parts
390+
converted_part = part_converter.convert_genai_part_to_a2a_part(part)
391+
return [converted_part] if converted_part else []
392+
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+
319405
logger.info(f"Found {len(json_data)} messages. Creating individual DataParts.")
320-
final_parts.append(create_a2ui_part(message))
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+
)
321420

322-
return final_parts
421+
return parts
323422

324-
# Don't send a2ui tool call to client
325-
elif (
326-
(function_call := part.function_call)
327-
and function_call.name
328-
== SendA2uiToClientToolset._SendA2uiJsonToClientTool.TOOL_NAME
329-
):
330-
return []
331423

332-
# Use default part converter for other types (images, etc)
333-
converted_part = part_converter.convert_genai_part_to_a2a_part(part)
424+
@experimental
425+
class A2uiEventConverter:
426+
"""An event converter that automatically injects the A2UI catalog into part conversion.
427+
428+
This allows text-based A2UI extraction and validation to work even when the
429+
catalog is session-specific.
430+
"""
334431

335-
logger.info(f"Returning converted part: {converted_part}")
336-
return [converted_part] if converted_part else []
432+
def __init__(self, catalog_key: str = "system:a2ui_catalog"):
433+
self._catalog_key = catalog_key
434+
435+
def __call__(
436+
self,
437+
event: "Event",
438+
invocation_context: "InvocationContext",
439+
task_id: Optional[str] = None,
440+
context_id: Optional[str] = None,
441+
part_converter_func: "GenAIPartToA2APartConverter" = part_converter.convert_genai_part_to_a2a_part,
442+
) -> list["A2AEvent"]:
443+
"""Converts an ADK event to A2A events, using the session catalog if available."""
444+
from google.adk.a2a.converters.event_converter import convert_event_to_a2a_events
445+
446+
catalog = invocation_context.session.state.get(self._catalog_key)
447+
if catalog:
448+
# Use the catalog-aware part converter
449+
effective_converter = A2uiPartConverter(catalog).convert
450+
else:
451+
effective_converter = part_converter_func
452+
453+
return convert_event_to_a2a_events(
454+
event,
455+
invocation_context,
456+
task_id,
457+
context_id,
458+
effective_converter,
459+
)

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,6 @@
1313
# limitations under the License.
1414

1515
from .parser import parse_response
16+
from .payload_fixer import parse_and_fix
1617

17-
__all__ = ["parse_response"]
18+
__all__ = ["parse_response", "parse_and_fix"]

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

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import json
1616
from typing import Tuple, Any
1717
from ..schema.constants import A2UI_DELIMITER
18+
from .payload_fixer import parse_and_fix
1819

1920

2021
def parse_response(content: str) -> Tuple[str, Any]:
@@ -47,10 +48,5 @@ def parse_response(content: str) -> Tuple[str, Any]:
4748
if not json_string_cleaned:
4849
raise ValueError("A2UI JSON part is empty.")
4950

50-
try:
51-
parsed_json_data = json.loads(json_string_cleaned)
52-
return text_part, parsed_json_data
53-
except json.JSONDecodeError as e:
54-
# Truncate string if it's too long to keep logs readable
55-
snippet = (json_string[:100] + "...") if len(json_string) > 100 else json_string
56-
raise ValueError(f"Failed to parse JSON: {snippet}") from e
51+
json_data = parse_and_fix(json_string_cleaned)
52+
return text_part, json_data

0 commit comments

Comments
 (0)