Skip to content

Commit 7050d5b

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

File tree

9 files changed

+786
-289
lines changed

9 files changed

+786
-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+
a2ui_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
Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
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
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+
@staticmethod
149+
def wrap_schema(a2ui_schema: dict[str, Any]) -> dict[str, Any]:
150+
"""Wraps the A2UI schema in an array object to support multiple parts.
151+
152+
Args:
153+
a2ui_schema: The A2UI schema to wrap.
154+
155+
Returns:
156+
The wrapped A2UI schema object.
157+
"""
158+
if not a2ui_schema:
159+
raise ValueError("A2UI schema is empty")
160+
return {"type": "array", "items": a2ui_schema}
161+
162+
async def _resolve_a2ui_enabled(self, ctx: ReadonlyContext) -> bool:
163+
return await resolve_a2ui_enabled(self._a2ui_enabled, ctx)
164+
165+
async def _resolve_a2ui_schema(self, ctx: ReadonlyContext) -> dict[str, Any]:
166+
return await resolve_a2ui_schema(self._a2ui_schema, ctx)
167+
168+
async def get_tools(
169+
self,
170+
readonly_context: Optional[ReadonlyContext] = None,
171+
) -> list[BaseTool]:
172+
"""Returns the list of tools provided by this toolset.
173+
174+
Args:
175+
readonly_context: The ReadonlyContext for resolving tool enablement.
176+
177+
Returns:
178+
A list of tools.
179+
"""
180+
use_ui = False
181+
if readonly_context is not None:
182+
use_ui = await self._resolve_a2ui_enabled(readonly_context)
183+
if use_ui:
184+
logger.info("A2UI is ENABLED, adding ui tools")
185+
return self._ui_tools
186+
else:
187+
logger.info("A2UI is DISABLED, not adding ui tools")
188+
return []
189+
190+
@experimental
191+
class SendA2uiJsonToClientTool(BaseTool):
192+
TOOL_NAME = "send_a2ui_json_to_client"
193+
A2UI_JSON_ARG_NAME = "a2ui_json"
194+
195+
def __init__(self, a2ui_schema: Union[dict[str, Any], A2uiSchemaProvider]):
196+
self._a2ui_schema = a2ui_schema
197+
super().__init__(
198+
name=self.TOOL_NAME,
199+
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."
200+
"Args:"
201+
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.",
202+
)
203+
204+
def _get_declaration(self) -> genai_types.FunctionDeclaration | None:
205+
return genai_types.FunctionDeclaration(
206+
name=self.name,
207+
description=self.description,
208+
parameters=genai_types.Schema(
209+
type=genai_types.Type.OBJECT,
210+
properties={
211+
self.A2UI_JSON_ARG_NAME: genai_types.Schema(
212+
type=genai_types.Type.STRING,
213+
description="valid A2UI JSON Schema to send to the client.",
214+
),
215+
},
216+
required=[self.A2UI_JSON_ARG_NAME],
217+
),
218+
)
219+
220+
async def get_a2ui_schema(self, ctx: ReadonlyContext) -> dict[str, Any]:
221+
"""Retrieves and wraps the A2UI schema.
222+
223+
Args:
224+
ctx: The ReadonlyContext for resolving the schema.
225+
226+
Returns:
227+
The wrapped A2UI schema.
228+
"""
229+
a2ui_schema = await resolve_a2ui_schema(self._a2ui_schema, ctx)
230+
return SendA2uiToClientToolset.wrap_schema(a2ui_schema)
231+
232+
async def process_llm_request(
233+
self, *, tool_context: ToolContext, llm_request: LlmRequest
234+
) -> None:
235+
await super().process_llm_request(
236+
tool_context=tool_context, llm_request=llm_request
237+
)
238+
239+
a2ui_schema = await self.get_a2ui_schema(tool_context)
240+
241+
llm_request.append_instructions(
242+
[
243+
f"""
244+
---BEGIN A2UI JSON SCHEMA---
245+
{json.dumps(a2ui_schema)}
246+
---END A2UI JSON SCHEMA---
247+
"""
248+
]
249+
)
250+
251+
logger.info("Added a2ui_schema to system instructions")
252+
253+
async def run_async(
254+
self, *, args: dict[str, Any], tool_context: ToolContext
255+
) -> Any:
256+
try:
257+
a2ui_json = args.get(self.A2UI_JSON_ARG_NAME)
258+
if not a2ui_json:
259+
raise ValueError(
260+
f"Failed to call tool {self.TOOL_NAME} because missing required arg {self.A2UI_JSON_ARG_NAME} "
261+
)
262+
263+
a2ui_json_payload = json.loads(a2ui_json)
264+
a2ui_schema = await self.get_a2ui_schema(tool_context)
265+
jsonschema.validate(
266+
instance=a2ui_json_payload, schema=a2ui_schema
267+
)
268+
269+
logger.info(
270+
f"Validated call to tool {self.TOOL_NAME} with {self.A2UI_JSON_ARG_NAME}"
271+
)
272+
273+
# Don't do a second LLM inference call for the None response
274+
tool_context.actions.skip_summarization = True
275+
276+
return None
277+
except Exception as e:
278+
err = f"Failed to call A2UI tool {self.TOOL_NAME}: {e}"
279+
logger.error(err)
280+
281+
return {"error": err}
282+
283+
@experimental
284+
class SendA2uiToClientPartConverter:
285+
286+
def __init__(self):
287+
self._a2ui_schema = None
288+
289+
def set_a2ui_schema(self, a2ui_schema: dict[str, Any]):
290+
self._a2ui_schema = a2ui_schema
291+
292+
def convert_genai_part_to_a2a_part(self, part: genai_types.Part) -> list[a2a_types.Part]:
293+
if (function_call := part.function_call) and function_call.name == SendA2uiJsonToClientTool.TOOL_NAME:
294+
if self._a2ui_schema is None:
295+
logger.error("A2UI schema is not set in part converter")
296+
return []
297+
298+
try:
299+
a2ui_json = function_call.args.get(SendA2uiJsonToClientTool.A2UI_JSON_ARG_NAME)
300+
if a2ui_json is None:
301+
raise ValueError(f"Failed to convert A2UI function call because required arg {SendA2uiJsonToClientTool.A2UI_JSON_ARG_NAME} not found in {str(part)}")
302+
if not a2ui_json.strip():
303+
logger.info("Empty a2ui_json, skipping")
304+
return []
305+
306+
logger.info(f"Converting a2ui json: {a2ui_json}")
307+
308+
json_data = json.loads(a2ui_json)
309+
if not isinstance(json_data, list):
310+
logger.info("Received a single JSON object, wrapping in a list for validation.")
311+
json_data = [json_data]
312+
313+
a2ui_schema_object = SendA2uiToClientToolset.wrap_schema(self._a2ui_schema)
314+
jsonschema.validate(
315+
instance=json_data, schema=a2ui_schema_object
316+
)
317+
318+
final_parts = []
319+
logger.info(f"Found {len(json_data)} messages. Creating individual DataParts.")
320+
for message in json_data:
321+
final_parts.append(create_a2ui_part(message))
322+
323+
return final_parts
324+
except Exception as e:
325+
logger.error(f"Error converting A2UI function call to A2A parts: {str(e)}")
326+
return []
327+
328+
# Don't send a2ui tool responses
329+
elif (function_response := part.function_response) and function_response.name == SendA2uiJsonToClientTool.TOOL_NAME:
330+
return []
331+
332+
# Use default part converter for other types (images, etc)
333+
converted_part = part_converter.convert_genai_part_to_a2a_part(part)
334+
335+
logger.info(f"Returning converted part: {converted_part}")
336+
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

File renamed without changes.

0 commit comments

Comments
 (0)