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
3533Usage 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:
8191import json
8292import logging
8393import 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
86104import jsonschema
87105
88106from a2a import types as a2a_types
89107from ...a2a import create_a2ui_part
108+ from a2ui .core .parser import parse_response , parse_and_fix
90109from a2ui .core .schema .catalog import A2uiCatalog
110+ from a2ui .core .schema .constants import A2UI_DELIMITER
91111from google .adk .a2a .converters import part_converter
92112from google .adk .agents .readonly_context import ReadonlyContext
93113from google .adk .models import LlmRequest
@@ -97,6 +117,12 @@ async def get_catalog(ctx: ReadonlyContext) -> A2uiCatalog:
97117from google .adk .utils .feature_decorator import experimental
98118from 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+
100126logger = logging .getLogger (__name__ )
101127
102128A2uiEnabledProvider : 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+ )
0 commit comments