Skip to content

Commit e669d81

Browse files
committed
Move example A2UI tools to SDK
1 parent dbc4187 commit e669d81

File tree

10 files changed

+796
-289
lines changed

10 files changed

+796
-289
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: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,3 +123,20 @@ def try_activate_a2ui_extension(context: RequestContext) -> bool:
123123
context.add_activated_extension(A2UI_EXTENSION_URI)
124124
return True
125125
return False
126+
127+
128+
def wrap_as_json_array(a2ui_schema: dict[str, Any]) -> dict[str, Any]:
129+
"""Wraps the A2UI schema in an array object to support multiple parts.
130+
131+
Args:
132+
a2ui_schema: The A2UI schema to wrap.
133+
134+
Returns:
135+
The wrapped A2UI schema object.
136+
137+
Raises:
138+
ValueError: If the A2UI schema is empty.
139+
"""
140+
if not a2ui_schema:
141+
raise ValueError("A2UI schema is empty")
142+
return {"type": "array", "items": a2ui_schema}
Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
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 = SendA2uiToClientPartConverter()
68+
converter.set_a2ui_schema(current_schema)
69+
parts = converter.convert_genai_part_to_a2a_part(llm_part)
70+
```
71+
"""
72+
73+
import inspect
74+
import json
75+
import logging
76+
from typing import Any, Awaitable, Callable, Optional, TypeAlias, Union
77+
78+
import jsonschema
79+
80+
from a2a import types as a2a_types
81+
from a2ui.a2ui_extension import create_a2ui_part, wrap_as_json_array
82+
from google.adk.a2a.converters import part_converter
83+
from google.adk.agents.readonly_context import ReadonlyContext
84+
from google.adk.models import LlmRequest
85+
from google.adk.tools import base_toolset
86+
from google.adk.tools.base_tool import BaseTool
87+
from google.adk.tools.tool_context import ToolContext
88+
from google.adk.utils.feature_decorator import experimental
89+
from google.genai import types as genai_types
90+
91+
logger = logging.getLogger(__name__)
92+
93+
A2uiEnabledProvider: TypeAlias = Callable[
94+
[ReadonlyContext], Union[bool, Awaitable[bool]]
95+
]
96+
A2uiSchemaProvider: TypeAlias = Callable[
97+
[ReadonlyContext], Union[dict[str, Any], Awaitable[dict[str, Any]]]
98+
]
99+
100+
async def resolve_a2ui_enabled(a2ui_enabled: Union[bool, A2uiEnabledProvider], ctx: ReadonlyContext) -> bool:
101+
"""The resolved self.a2ui_enabled field to construct instruction for this agent.
102+
103+
Args:
104+
ctx: The ReadonlyContext to resolve the provider with.
105+
106+
Returns:
107+
If A2UI is enabled, return True. Otherwise, return False.
108+
"""
109+
if isinstance(a2ui_enabled, bool):
110+
return a2ui_enabled
111+
else:
112+
a2ui_enabled = a2ui_enabled(ctx)
113+
if inspect.isawaitable(a2ui_enabled):
114+
a2ui_enabled = await a2ui_enabled
115+
return a2ui_enabled
116+
117+
async def resolve_a2ui_schema(a2ui_schema: Union[dict[str, Any], A2uiSchemaProvider], ctx: ReadonlyContext) -> dict[str, Any]:
118+
"""The resolved self.a2ui_schema field to construct instruction for this agent.
119+
120+
Args:
121+
ctx: The ReadonlyContext to resolve the provider with.
122+
123+
Returns:
124+
The A2UI schema to send to the client.
125+
"""
126+
if isinstance(a2ui_schema, dict):
127+
return a2ui_schema
128+
else:
129+
a2ui_schema = a2ui_schema(ctx)
130+
if inspect.isawaitable(a2ui_schema):
131+
a2ui_schema = await a2ui_schema
132+
return a2ui_schema
133+
134+
@experimental
135+
class SendA2uiToClientToolset(base_toolset.BaseToolset):
136+
"""A toolset that provides A2UI Tools and can be enabled/disabled."""
137+
138+
def __init__(
139+
self,
140+
a2ui_enabled: Union[bool, A2uiEnabledProvider],
141+
a2ui_schema: Union[dict[str, Any], A2uiSchemaProvider],
142+
):
143+
super().__init__()
144+
self._a2ui_enabled = a2ui_enabled
145+
self._a2ui_schema = a2ui_schema
146+
self._ui_tools = [SendA2uiJsonToClientTool(self._a2ui_schema)]
147+
148+
async def _resolve_a2ui_enabled(self, ctx: ReadonlyContext) -> bool:
149+
return await resolve_a2ui_enabled(self._a2ui_enabled, ctx)
150+
151+
async def _resolve_a2ui_schema(self, ctx: ReadonlyContext) -> dict[str, Any]:
152+
return await resolve_a2ui_schema(self._a2ui_schema, ctx)
153+
154+
async def get_tools(
155+
self,
156+
readonly_context: Optional[ReadonlyContext] = None,
157+
) -> list[BaseTool]:
158+
"""Returns the list of tools provided by this toolset.
159+
160+
Args:
161+
readonly_context: The ReadonlyContext for resolving tool enablement.
162+
163+
Returns:
164+
A list of tools.
165+
"""
166+
use_ui = False
167+
if readonly_context is not None:
168+
use_ui = await self._resolve_a2ui_enabled(readonly_context)
169+
if use_ui:
170+
logger.info("A2UI is ENABLED, adding ui tools")
171+
return self._ui_tools
172+
else:
173+
logger.info("A2UI is DISABLED, not adding ui tools")
174+
return []
175+
176+
@experimental
177+
class SendA2uiJsonToClientTool(BaseTool):
178+
TOOL_NAME = "send_a2ui_json_to_client"
179+
A2UI_JSON_ARG_NAME = "a2ui_json"
180+
181+
def __init__(self, a2ui_schema: Union[dict[str, Any], A2uiSchemaProvider]):
182+
self._a2ui_schema = a2ui_schema
183+
super().__init__(
184+
name=self.TOOL_NAME,
185+
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."
186+
"Args:"
187+
f" {self.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.",
188+
)
189+
190+
def _get_declaration(self) -> genai_types.FunctionDeclaration | None:
191+
return genai_types.FunctionDeclaration(
192+
name=self.name,
193+
description=self.description,
194+
parameters=genai_types.Schema(
195+
type=genai_types.Type.OBJECT,
196+
properties={
197+
self.A2UI_JSON_ARG_NAME: genai_types.Schema(
198+
type=genai_types.Type.STRING,
199+
description="valid A2UI JSON Schema to send to the client.",
200+
),
201+
},
202+
required=[self.A2UI_JSON_ARG_NAME],
203+
),
204+
)
205+
206+
async def get_a2ui_schema(self, ctx: ReadonlyContext) -> dict[str, Any]:
207+
"""Retrieves and wraps the A2UI schema.
208+
209+
Args:
210+
ctx: The ReadonlyContext for resolving the schema.
211+
212+
Returns:
213+
The wrapped A2UI schema.
214+
"""
215+
a2ui_schema = await resolve_a2ui_schema(self._a2ui_schema, ctx)
216+
return wrap_as_json_array(a2ui_schema)
217+
218+
async def process_llm_request(
219+
self, *, tool_context: ToolContext, llm_request: LlmRequest
220+
) -> None:
221+
await super().process_llm_request(
222+
tool_context=tool_context, llm_request=llm_request
223+
)
224+
225+
a2ui_schema = await self.get_a2ui_schema(tool_context)
226+
227+
llm_request.append_instructions(
228+
[
229+
f"""
230+
---BEGIN A2UI JSON SCHEMA---
231+
{json.dumps(a2ui_schema)}
232+
---END A2UI JSON SCHEMA---
233+
"""
234+
]
235+
)
236+
237+
logger.info("Added a2ui_schema to system instructions")
238+
239+
async def run_async(
240+
self, *, args: dict[str, Any], tool_context: ToolContext
241+
) -> Any:
242+
try:
243+
a2ui_json = args.get(self.A2UI_JSON_ARG_NAME)
244+
if not a2ui_json:
245+
raise ValueError(
246+
f"Failed to call tool {self.TOOL_NAME} because missing required arg {self.A2UI_JSON_ARG_NAME} "
247+
)
248+
249+
a2ui_json_payload = json.loads(a2ui_json)
250+
a2ui_schema = await self.get_a2ui_schema(tool_context)
251+
jsonschema.validate(
252+
instance=a2ui_json_payload, schema=a2ui_schema
253+
)
254+
255+
logger.info(
256+
f"Validated call to tool {self.TOOL_NAME} with {self.A2UI_JSON_ARG_NAME}"
257+
)
258+
259+
# Don't do a second LLM inference call for the None response
260+
tool_context.actions.skip_summarization = True
261+
262+
return None
263+
except Exception as e:
264+
err = f"Failed to call A2UI tool {self.TOOL_NAME}: {e}"
265+
logger.error(err)
266+
267+
return {"error": err}
268+
269+
@experimental
270+
class SendA2uiToClientPartConverter:
271+
272+
def __init__(self):
273+
self._a2ui_schema = None
274+
275+
def set_a2ui_schema(self, a2ui_schema: dict[str, Any]):
276+
self._a2ui_schema = a2ui_schema
277+
278+
def convert_genai_part_to_a2a_part(self, part: genai_types.Part) -> list[a2a_types.Part]:
279+
if (function_call := part.function_call) and function_call.name == SendA2uiJsonToClientTool.TOOL_NAME:
280+
if self._a2ui_schema is None:
281+
logger.error("A2UI schema is not set in part converter")
282+
return []
283+
284+
try:
285+
a2ui_json = function_call.args.get(SendA2uiJsonToClientTool.A2UI_JSON_ARG_NAME)
286+
if a2ui_json is None:
287+
raise ValueError(f"Failed to convert A2UI function call because required arg {SendA2uiJsonToClientTool.A2UI_JSON_ARG_NAME} not found in {str(part)}")
288+
if not a2ui_json.strip():
289+
logger.info("Empty a2ui_json, skipping")
290+
return []
291+
292+
logger.info(f"Converting a2ui json: {a2ui_json}")
293+
294+
json_data = json.loads(a2ui_json)
295+
if not isinstance(json_data, list):
296+
logger.info("Received a single JSON object, wrapping in a list for validation.")
297+
json_data = [json_data]
298+
299+
a2ui_schema_object = wrap_as_json_array(self._a2ui_schema)
300+
jsonschema.validate(
301+
instance=json_data, schema=a2ui_schema_object
302+
)
303+
304+
final_parts = []
305+
logger.info(f"Found {len(json_data)} messages. Creating individual DataParts.")
306+
for message in json_data:
307+
final_parts.append(create_a2ui_part(message))
308+
309+
return final_parts
310+
except Exception as e:
311+
logger.error(f"Error converting A2UI function call to A2A parts: {str(e)}")
312+
return []
313+
314+
# Don't send a2ui tool responses
315+
elif (function_response := part.function_response) and function_response.name == SendA2uiJsonToClientTool.TOOL_NAME:
316+
return []
317+
318+
# Use default part converter for other types (images, etc)
319+
converted_part = part_converter.convert_genai_part_to_a2a_part(part)
320+
321+
logger.info(f"Returning converted part: {converted_part}")
322+
return [converted_part] if converted_part else []

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,13 @@ 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+
105+
def test_wrap_as_json_array():
106+
schema = {"type": "object"}
107+
wrapped = a2ui_extension.wrap_as_json_array(schema)
108+
assert wrapped == {"type": "array", "items": schema}
109+
110+
import pytest
111+
with pytest.raises(ValueError):
112+
a2ui_extension.wrap_as_json_array({})

0 commit comments

Comments
 (0)