Skip to content

Commit 3a6b517

Browse files
committed
Move example A2UI tools to SDK
1 parent 79df6e0 commit 3a6b517

File tree

10 files changed

+750
-291
lines changed

10 files changed

+750
-291
lines changed

a2a_agents/python/a2ui_extension/README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# A2UI Extension Implementation
22

3-
This is the Python implementation of the a2ui extension.
3+
a2ui_extension.py is the Python implementation of the a2ui extension.
4+
send_a2ui_to_client_toolset.py is an example Python implementation of using ADK toolcalls to implement A2UI.
45

56
## Running Tests
67

@@ -13,7 +14,7 @@ This is the Python implementation of the a2ui extension.
1314
2. Run the tests
1415

1516
```bash
16-
uv run --with pytest pytest tests/test_extension.py
17+
uv run --with pytest pytest tests/*.py
1718
```
1819

1920
## Disclaimer

a2a_agents/python/a2ui_extension/src/a2ui/a2ui_extension.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,3 +123,4 @@ def try_activate_a2ui_extension(context: RequestContext) -> bool:
123123
context.add_activated_extension(A2UI_EXTENSION_URI)
124124
return True
125125
return False
126+
Lines changed: 332 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,332 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Module for the A2UI Toolset and Part Converter.
16+
17+
This module provides the necessary components to enable an agent to send A2UI (Agent-to-User Interface)
18+
JSON payloads to a client. It includes a toolset for managing A2UI tools, a specific tool for the LLM
19+
to send JSON, and a part converter to translate the LLM's tool calls into A2A (Agent-to-Agent) parts.
20+
21+
Key Components:
22+
* `SendA2uiToClientToolset`: The main entry point. It accepts providers for determining
23+
if A2UI is enabled and for fetching the A2UI schema. It manages the lifecycle of the
24+
`_SendA2uiJsonToClientTool`.
25+
* `_SendA2uiJsonToClientTool`: A tool exposed to the LLM. It allows the LLM to "call" a function
26+
that effectively sends a JSON payload to the client. This tool validates the JSON against
27+
the provided schema. It automatically wraps the provided schema in an array structure,
28+
instructing the LLM that it can send a list of UI items.
29+
* `SendA2uiToClientPartConverter`: A utility class that intercepts the `send_a2ui_json_to_client`
30+
tool calls from the LLM and converts them into `a2a_types.Part` objects, which are then
31+
processed by the A2A system.
32+
33+
Usage Examples:
34+
35+
1. Defining Providers:
36+
You can use simple values or callables (sync or async) for enablement and schema.
37+
38+
```python
39+
# Simple boolean and dict
40+
toolset = SendA2uiToClientToolset(a2ui_enabled=True, a2ui_schema=MY_SCHEMA)
41+
42+
# Async providers
43+
async def check_enabled(ctx: ReadonlyContext) -> bool:
44+
return await some_condition(ctx)
45+
46+
async def get_schema(ctx: ReadonlyContext) -> dict[str, Any]:
47+
return await fetch_schema(ctx)
48+
49+
toolset = SendA2uiToClientToolset(a2ui_enabled=check_enabled, a2ui_schema=get_schema)
50+
```
51+
52+
2. Integration with Agent:
53+
Typically used when initializing an agent's toolset.
54+
55+
```python
56+
# In your agent initialization
57+
self.a2ui_toolset = SendA2uiToClientToolset(
58+
a2ui_enabled=self._is_a2ui_enabled,
59+
a2ui_schema=self._get_a2ui_schema
60+
)
61+
```
62+
63+
3. Part Conversion:
64+
The converter needs to be aware of the schema to validate incoming tool calls.
65+
66+
```python
67+
converter = SendA2uiToClientToolset.get_part_converter()
68+
parts = converter.convert_genai_part_to_a2a_part(llm_part)
69+
```
70+
"""
71+
72+
import inspect
73+
import json
74+
import logging
75+
from typing import Any, Awaitable, Callable, Optional, TypeAlias, Union
76+
77+
import jsonschema
78+
79+
from a2a import types as a2a_types
80+
from a2ui.a2ui_extension import create_a2ui_part
81+
from google.adk.a2a.converters import part_converter
82+
from google.adk.agents.readonly_context import ReadonlyContext
83+
from google.adk.models import LlmRequest
84+
from google.adk.tools import base_toolset
85+
from google.adk.tools.base_tool import BaseTool
86+
from google.adk.tools.tool_context import ToolContext
87+
from google.adk.utils.feature_decorator import experimental
88+
from google.genai import types as genai_types
89+
90+
logger = logging.getLogger(__name__)
91+
92+
A2uiEnabledProvider: TypeAlias = Callable[
93+
[ReadonlyContext], Union[bool, Awaitable[bool]]
94+
]
95+
A2uiSchemaProvider: TypeAlias = Callable[
96+
[ReadonlyContext], Union[dict[str, Any], Awaitable[dict[str, Any]]]
97+
]
98+
99+
async def resolve_a2ui_enabled(a2ui_enabled: Union[bool, A2uiEnabledProvider], ctx: ReadonlyContext) -> bool:
100+
"""The resolved self.a2ui_enabled field to construct instruction for this agent.
101+
102+
Args:
103+
ctx: The ReadonlyContext to resolve the provider with.
104+
105+
Returns:
106+
If A2UI is enabled, return True. Otherwise, return False.
107+
"""
108+
if isinstance(a2ui_enabled, bool):
109+
return a2ui_enabled
110+
else:
111+
a2ui_enabled = a2ui_enabled(ctx)
112+
if inspect.isawaitable(a2ui_enabled):
113+
a2ui_enabled = await a2ui_enabled
114+
return a2ui_enabled
115+
116+
async def resolve_a2ui_schema(a2ui_schema: Union[dict[str, Any], A2uiSchemaProvider], ctx: ReadonlyContext) -> dict[str, Any]:
117+
"""The resolved self.a2ui_schema field to construct instruction for this agent.
118+
119+
Args:
120+
ctx: The ReadonlyContext to resolve the provider with.
121+
122+
Returns:
123+
The A2UI schema to send to the client.
124+
"""
125+
if isinstance(a2ui_schema, dict):
126+
return a2ui_schema
127+
else:
128+
a2ui_schema = a2ui_schema(ctx)
129+
if inspect.isawaitable(a2ui_schema):
130+
a2ui_schema = await a2ui_schema
131+
return a2ui_schema
132+
133+
@experimental
134+
class SendA2uiToClientPartConverter:
135+
136+
def __init__(self, toolname: str):
137+
self._toolname = toolname
138+
139+
def convert_genai_part_to_a2a_part(self, part: genai_types.Part) -> list[a2a_types.Part]:
140+
if (function_response := part.function_response) and function_response.name == self._toolname:
141+
if "error" in function_response.response:
142+
logger.warning(f"A2UI tool call failed: {function_response.response['error']}")
143+
return []
144+
145+
# The tool returns the list of messages directly on success
146+
json_data = function_response.response.get("result")
147+
if not json_data:
148+
logger.info("No result in A2UI tool response")
149+
return []
150+
151+
final_parts = []
152+
for message in json_data:
153+
logger.info(f"Found {len(json_data)} messages. Creating individual DataParts.")
154+
final_parts.append(create_a2ui_part(message))
155+
156+
return final_parts
157+
158+
# Don't send a2ui tool call to client
159+
elif (function_call := part.function_call) and function_call.name == self._toolname:
160+
return []
161+
162+
# Use default part converter for other types (images, etc)
163+
converted_part = part_converter.convert_genai_part_to_a2a_part(part)
164+
165+
logger.info(f"Returning converted part: {converted_part}")
166+
return [converted_part] if converted_part else []
167+
168+
@experimental
169+
class SendA2uiToClientToolset(base_toolset.BaseToolset):
170+
"""A toolset that provides A2UI Tools and can be enabled/disabled."""
171+
172+
A2UI_JSON_ARG_NAME = "a2ui_json"
173+
174+
def __init__(
175+
self,
176+
a2ui_enabled: Union[bool, A2uiEnabledProvider],
177+
a2ui_schema: Union[dict[str, Any], A2uiSchemaProvider],
178+
):
179+
super().__init__()
180+
self._a2ui_enabled = a2ui_enabled
181+
self._a2ui_schema = a2ui_schema
182+
self._ui_tools = [self._SendA2uiJsonToClientTool(self._a2ui_schema)]
183+
184+
async def _resolve_a2ui_enabled(self, ctx: ReadonlyContext) -> bool:
185+
return await resolve_a2ui_enabled(self._a2ui_enabled, ctx)
186+
187+
async def _resolve_a2ui_schema(self, ctx: ReadonlyContext) -> dict[str, Any]:
188+
return await resolve_a2ui_schema(self._a2ui_schema, ctx)
189+
190+
@classmethod
191+
def get_part_converter(cls) -> SendA2uiToClientPartConverter:
192+
return SendA2uiToClientPartConverter(cls._SendA2uiJsonToClientTool.TOOL_NAME)
193+
194+
async def get_tools(
195+
self,
196+
readonly_context: Optional[ReadonlyContext] = None,
197+
) -> list[BaseTool]:
198+
"""Returns the list of tools provided by this toolset.
199+
200+
Args:
201+
readonly_context: The ReadonlyContext for resolving tool enablement.
202+
203+
Returns:
204+
A list of tools.
205+
"""
206+
use_ui = False
207+
if readonly_context is not None:
208+
use_ui = await self._resolve_a2ui_enabled(readonly_context)
209+
if use_ui:
210+
logger.info("A2UI is ENABLED, adding ui tools")
211+
return self._ui_tools
212+
else:
213+
logger.info("A2UI is DISABLED, not adding ui tools")
214+
return []
215+
216+
@staticmethod
217+
def wrap_as_json_array(a2ui_schema: dict[str, Any]) -> dict[str, Any]:
218+
"""Wraps the A2UI schema in an array object to support multiple parts.
219+
220+
Args:
221+
a2ui_schema: The A2UI schema to wrap.
222+
223+
Returns:
224+
The wrapped A2UI schema object.
225+
226+
Raises:
227+
ValueError: If the A2UI schema is empty.
228+
"""
229+
if not a2ui_schema:
230+
raise ValueError("A2UI schema is empty")
231+
return {"type": "array", "items": a2ui_schema}
232+
233+
class _SendA2uiJsonToClientTool(BaseTool):
234+
TOOL_NAME = "send_a2ui_json_to_client"
235+
236+
def __init__(self, a2ui_schema: Union[dict[str, Any], A2uiSchemaProvider]):
237+
self._a2ui_schema = a2ui_schema
238+
super().__init__(
239+
name=self.TOOL_NAME,
240+
description="Sends A2UI JSON to the client to render rich UI for the user. This tool can be called multiple times in the same call to render multiple UI surfaces."
241+
"Args:"
242+
f" {SendA2uiToClientToolset.A2UI_JSON_ARG_NAME}: Valid A2UI JSON Schema to send to the client. The A2UI JSON Schema definition is between ---BEGIN A2UI JSON SCHEMA--- and ---END A2UI JSON SCHEMA--- in the system instructions.",
243+
)
244+
245+
def _get_declaration(self) -> genai_types.FunctionDeclaration | None:
246+
return genai_types.FunctionDeclaration(
247+
name=self.name,
248+
description=self.description,
249+
parameters=genai_types.Schema(
250+
type=genai_types.Type.OBJECT,
251+
properties={
252+
SendA2uiToClientToolset.A2UI_JSON_ARG_NAME: genai_types.Schema(
253+
type=genai_types.Type.STRING,
254+
description="valid A2UI JSON Schema to send to the client.",
255+
),
256+
},
257+
required=[SendA2uiToClientToolset.A2UI_JSON_ARG_NAME],
258+
),
259+
)
260+
261+
async def get_a2ui_schema(self, ctx: ReadonlyContext) -> dict[str, Any]:
262+
"""Retrieves and wraps the A2UI schema.
263+
264+
Args:
265+
ctx: The ReadonlyContext for resolving the schema.
266+
267+
Returns:
268+
The wrapped A2UI schema.
269+
"""
270+
a2ui_schema = await resolve_a2ui_schema(self._a2ui_schema, ctx)
271+
return SendA2uiToClientToolset.wrap_as_json_array(a2ui_schema)
272+
273+
async def process_llm_request(
274+
self, *, tool_context: ToolContext, llm_request: LlmRequest
275+
) -> None:
276+
await super().process_llm_request(
277+
tool_context=tool_context, llm_request=llm_request
278+
)
279+
280+
a2ui_schema = await self.get_a2ui_schema(tool_context)
281+
282+
llm_request.append_instructions(
283+
[
284+
f"""
285+
---BEGIN A2UI JSON SCHEMA---
286+
{json.dumps(a2ui_schema)}
287+
---END A2UI JSON SCHEMA---
288+
"""
289+
]
290+
)
291+
292+
logger.info("Added a2ui_schema to system instructions")
293+
294+
async def run_async(
295+
self, *, args: dict[str, Any], tool_context: ToolContext
296+
) -> Any:
297+
try:
298+
a2ui_json = args.get(SendA2uiToClientToolset.A2UI_JSON_ARG_NAME)
299+
if not a2ui_json:
300+
raise ValueError(
301+
f"Failed to call tool {self.TOOL_NAME} because missing required arg {SendA2uiToClientToolset.A2UI_JSON_ARG_NAME} "
302+
)
303+
304+
a2ui_json_payload = json.loads(a2ui_json)
305+
306+
# Auto-wrap single object in list
307+
if not isinstance(a2ui_json_payload, list):
308+
logger.info("Received a single JSON object, wrapping in a list for validation.")
309+
a2ui_json_payload = [a2ui_json_payload]
310+
311+
a2ui_schema = await self.get_a2ui_schema(tool_context)
312+
jsonschema.validate(
313+
instance=a2ui_json_payload, schema=a2ui_schema
314+
)
315+
316+
logger.info(
317+
f"Validated call to tool {self.TOOL_NAME} with {SendA2uiToClientToolset.A2UI_JSON_ARG_NAME}"
318+
)
319+
320+
# Don't do a second LLM inference call for the JSON response
321+
tool_context.actions.skip_summarization = True
322+
323+
# Return the validated JSON so the converter can use it.
324+
# We return it in a dict under "result" key for consistent JSON structure.
325+
return {"result": a2ui_json_payload}
326+
327+
except Exception as e:
328+
err = f"Failed to call A2UI tool {self.TOOL_NAME}: {e}"
329+
logger.error(err)
330+
331+
return {"error": err}
332+

a2a_agents/python/a2ui_extension/tests/test_extension.py renamed to a2a_agents/python/a2ui_extension/tests/test_a2ui_extension.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,5 @@ def test_try_activate_a2ui_extension_not_requested():
100100

101101
assert not a2ui_extension.try_activate_a2ui_extension(context)
102102
context.add_activated_extension.assert_not_called()
103+
104+

0 commit comments

Comments
 (0)