Skip to content

Commit 8acb82e

Browse files
committed
Move example A2UI tools to SDK
1 parent d799665 commit 8acb82e

File tree

8 files changed

+615
-268
lines changed

8 files changed

+615
-268
lines changed
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
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+
import json
16+
import jsonschema
17+
import logging
18+
import inspect
19+
from typing import Any, List, Optional, TypeAlias, Callable, Union, Awaitable
20+
21+
from a2a import types as a2a_types
22+
from google.genai import types as genai_types
23+
24+
from google.adk.a2a.converters import part_converter
25+
from google.adk.models import LlmRequest
26+
from google.adk.tools.base_tool import BaseTool
27+
from google.adk.tools import base_toolset
28+
from google.adk.tools.tool_context import ToolContext
29+
from google.adk.agents.readonly_context import ReadonlyContext
30+
from google.adk.utils.feature_decorator import experimental
31+
32+
from a2ui.a2ui_extension import create_a2ui_part
33+
34+
logger = logging.getLogger(__name__)
35+
36+
37+
@experimental
38+
class A2uiToolset(base_toolset.BaseToolset):
39+
"""A toolset that provides A2UI Tools and can be enabled/disabled."""
40+
41+
A2uiEnabledProvider: TypeAlias = Callable[
42+
[ReadonlyContext], Union[bool, Awaitable[bool]]
43+
]
44+
A2uiSchemaProvider: TypeAlias = Callable[
45+
[ReadonlyContext], Union[dict[str, Any], Awaitable[dict[str, Any]]]
46+
]
47+
48+
def __init__(
49+
self,
50+
a2ui_enabled: Union[bool, A2uiEnabledProvider],
51+
a2ui_schema: Union[dict[str, Any], A2uiSchemaProvider],
52+
):
53+
super().__init__()
54+
self._a2ui_enabled = a2ui_enabled
55+
self._a2ui_schema = a2ui_schema
56+
self._ui_tools = [SendA2uiJsonToClientTool(self)]
57+
58+
async def canonical_a2ui_enabled(self) -> bool:
59+
"""The resolved self.instruction field to construct instruction for this agent.
60+
61+
This method is only for use by Agent Development Kit.
62+
63+
Args:
64+
ctx: The context to retrieve the session state.
65+
66+
Returns:
67+
If a2ui is enabled, return True. Otherwise, return False.
68+
"""
69+
if isinstance(self._a2ui_enabled, bool):
70+
return self._a2ui_enabled
71+
else:
72+
a2ui_enabled = self._a2ui_enabled()
73+
if inspect.isawaitable(a2ui_enabled):
74+
a2ui_enabled = await a2ui_enabled
75+
return a2ui_enabled
76+
77+
async def canonical_a2ui_schema(self) -> dict[str, Any]:
78+
"""The resolved self.instruction field to construct instruction for this agent.
79+
80+
This method is only for use by Agent Development Kit.
81+
82+
Args:
83+
ctx: The context to retrieve the session state.
84+
85+
Returns:
86+
The A2UI schema to send to the client.
87+
"""
88+
if isinstance(self._a2ui_schema, dict):
89+
return self._a2ui_schema
90+
else:
91+
a2ui_schema = self._a2ui_schema()
92+
if inspect.isawaitable(a2ui_schema):
93+
a2ui_schema = await a2ui_schema
94+
return a2ui_schema
95+
96+
async def get_tools(
97+
self,
98+
readonly_context: Optional[ReadonlyContext] = None,
99+
) -> List[BaseTool]:
100+
use_ui = self._a2ui_enabled
101+
if use_ui:
102+
logger.info("A2UI is ENABLED, adding ui tools")
103+
return self._ui_tools
104+
else:
105+
logger.info("A2UI is DISABLED, not adding ui tools")
106+
return []
107+
108+
@experimental
109+
class SendA2uiJsonToClientTool(BaseTool):
110+
TOOL_NAME = "send_a2ui_json_to_client"
111+
A2UI_JSON_ARG_NAME = "a2ui_json"
112+
113+
def __init__(self, toolset: A2uiToolset):
114+
self._toolset = toolset
115+
super().__init__(
116+
name=self.TOOL_NAME,
117+
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."
118+
"Args:"
119+
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.",
120+
)
121+
122+
def _get_declaration(self) -> genai_types.FunctionDeclaration | None:
123+
return genai_types.FunctionDeclaration(
124+
name=self.name,
125+
description=self.description,
126+
parameters=genai_types.Schema(
127+
type=genai_types.Type.OBJECT,
128+
properties={
129+
self.A2UI_JSON_ARG_NAME: genai_types.Schema(
130+
type=genai_types.Type.STRING,
131+
description="valid A2UI JSON Schema to send to the client.",
132+
),
133+
},
134+
required=[self.A2UI_JSON_ARG_NAME],
135+
),
136+
)
137+
138+
async def get_a2ui_schema(self) -> dict[str, Any]:
139+
a2ui_schema = await self._toolset.canonical_a2ui_schema()
140+
print(f"!!!!! A2UI Schema: {a2ui_schema}")
141+
if not a2ui_schema:
142+
raise ValueError("A2UI schema is empty")
143+
a2ui_schema_object = {"type": "array", "items": a2ui_schema} # Make a list since we support multiple parts in this tool call
144+
return a2ui_schema_object
145+
146+
async def process_llm_request(
147+
self, *, tool_context: ToolContext, llm_request: LlmRequest
148+
) -> None:
149+
await super().process_llm_request(
150+
tool_context=tool_context, llm_request=llm_request
151+
)
152+
153+
a2ui_schema = await self.get_a2ui_schema()
154+
155+
llm_request.append_instructions(
156+
[
157+
f"""
158+
---BEGIN A2UI JSON SCHEMA---
159+
{json.dumps(a2ui_schema)}
160+
---END A2UI JSON SCHEMA---
161+
"""
162+
]
163+
)
164+
165+
logger.info("Added a2ui_schema to system instructions")
166+
167+
async def run_async(
168+
self, *, args: dict[str, Any], tool_context: ToolContext
169+
) -> Any:
170+
try:
171+
a2ui_json = args.get(self.A2UI_JSON_ARG_NAME)
172+
if not a2ui_json:
173+
raise ValueError(
174+
f"Failed to call tool {self.TOOL_NAME} because missing required arg {self.A2UI_JSON_ARG_NAME} "
175+
)
176+
177+
a2ui_json_payload = json.loads(a2ui_json)
178+
a2ui_schema = await self.get_a2ui_schema()
179+
jsonschema.validate(
180+
instance=a2ui_json_payload, schema=a2ui_schema
181+
)
182+
183+
logger.info(
184+
f"Validated call to tool {self.TOOL_NAME} with {self.A2UI_JSON_ARG_NAME}"
185+
)
186+
187+
# Don't do a second LLM inference call for the None response
188+
tool_context.actions.skip_summarization = True
189+
190+
return None
191+
except Exception as e:
192+
err = f"Failed to call A2UI tool {self.TOOL_NAME}: {e}"
193+
logger.error(err)
194+
195+
return {"error": err}
196+
197+
@experimental
198+
class A2uiPartConverter:
199+
200+
def __init__(self):
201+
self._a2ui_schema = None
202+
203+
def set_a2ui_schema(self, a2ui_schema: dict[str, Any]):
204+
self._a2ui_schema = a2ui_schema
205+
206+
def convert_genai_part_to_a2a_part(self, part: genai_types.Part) -> List[a2a_types.Part]:
207+
if (function_call := part.function_call) and function_call.name == SendA2uiJsonToClientTool.TOOL_NAME:
208+
if self._a2ui_schema is None:
209+
logger.error("A2UI schema is not set in part converter")
210+
return []
211+
212+
try:
213+
a2ui_json = function_call.args.get(SendA2uiJsonToClientTool.A2UI_JSON_ARG_NAME)
214+
if a2ui_json is None:
215+
raise ValueError(f"Failed to convert A2UI function call because required arg {SendA2uiJsonToClientTool.A2UI_JSON_ARG_NAME} not found in {str(part)}")
216+
if not a2ui_json.strip():
217+
logger.info("Empty a2ui_json, skipping")
218+
return []
219+
220+
logger.info(f"Converting a2ui json: {a2ui_json}")
221+
222+
json_data = json.loads(a2ui_json)
223+
if not isinstance(json_data, list):
224+
logger.info("Received a single JSON object, wrapping in a list for validation.")
225+
json_data = [json_data]
226+
227+
a2ui_schema_object = {"type": "array", "items": self._a2ui_schema} # Make a list since we support multiple parts in this tool call
228+
jsonschema.validate(
229+
instance=json_data, schema=a2ui_schema_object
230+
)
231+
232+
final_parts = []
233+
if isinstance(json_data, list):
234+
logger.info( f"Found {len(json_data)} messages. Creating individual DataParts." )
235+
for message in json_data:
236+
final_parts.append(create_a2ui_part(message))
237+
else:
238+
# Handle the case where a single JSON object is returned
239+
logger.info("Received a single JSON object. Creating a DataPart." )
240+
final_parts.append(create_a2ui_part(json_data))
241+
242+
return final_parts
243+
except Exception as e:
244+
logger.error(f"Error converting A2UI function call to A2A parts: {str(e)}")
245+
return []
246+
247+
# Don't send a2ui tool responses
248+
elif (function_response := part.function_response) and function_response.name == SendA2uiJsonToClientTool.TOOL_NAME:
249+
return []
250+
251+
# Use default part converter for other types (images, etc)
252+
converted_part = part_converter.convert_genai_part_to_a2a_part(part)
253+
254+
logger.info(f"Returning converted part: {converted_part}" )
255+
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)