Skip to content

Commit 5356f20

Browse files
seanzhougooglecopybara-github
authored andcommitted
chore: Add a2a log utils for formatting a2a reqeust/response logs
PiperOrigin-RevId: 776026554
1 parent ed09cd8 commit 5356f20

File tree

4 files changed

+880
-0
lines changed

4 files changed

+880
-0
lines changed

src/google/adk/a2a/logs/__init__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
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+
# http://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.

src/google/adk/a2a/logs/log_utils.py

Lines changed: 349 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,349 @@
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+
# http://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+
"""Utility functions for structured A2A request and response logging."""
16+
17+
from __future__ import annotations
18+
19+
import json
20+
import sys
21+
22+
try:
23+
from a2a.types import DataPart as A2ADataPart
24+
from a2a.types import Message as A2AMessage
25+
from a2a.types import Part as A2APart
26+
from a2a.types import SendMessageRequest
27+
from a2a.types import SendMessageResponse
28+
from a2a.types import Task as A2ATask
29+
from a2a.types import TextPart as A2ATextPart
30+
except ImportError as e:
31+
if sys.version_info < (3, 10):
32+
raise ImportError(
33+
"A2A Tool requires Python 3.10 or above. Please upgrade your Python"
34+
" version."
35+
) from e
36+
else:
37+
raise e
38+
39+
40+
# Constants
41+
_NEW_LINE = "\n"
42+
_EXCLUDED_PART_FIELD = {"file": {"bytes"}}
43+
44+
45+
def _is_a2a_task(obj) -> bool:
46+
"""Check if an object is an A2A Task, with fallback for isinstance issues."""
47+
try:
48+
return isinstance(obj, A2ATask)
49+
except (TypeError, AttributeError):
50+
return type(obj).__name__ == "Task" and hasattr(obj, "status")
51+
52+
53+
def _is_a2a_message(obj) -> bool:
54+
"""Check if an object is an A2A Message, with fallback for isinstance issues."""
55+
try:
56+
return isinstance(obj, A2AMessage)
57+
except (TypeError, AttributeError):
58+
return type(obj).__name__ == "Message" and hasattr(obj, "role")
59+
60+
61+
def _is_a2a_text_part(obj) -> bool:
62+
"""Check if an object is an A2A TextPart, with fallback for isinstance issues."""
63+
try:
64+
return isinstance(obj, A2ATextPart)
65+
except (TypeError, AttributeError):
66+
return type(obj).__name__ == "TextPart" and hasattr(obj, "text")
67+
68+
69+
def _is_a2a_data_part(obj) -> bool:
70+
"""Check if an object is an A2A DataPart, with fallback for isinstance issues."""
71+
try:
72+
return isinstance(obj, A2ADataPart)
73+
except (TypeError, AttributeError):
74+
return type(obj).__name__ == "DataPart" and hasattr(obj, "data")
75+
76+
77+
def build_message_part_log(part: A2APart) -> str:
78+
"""Builds a log representation of an A2A message part.
79+
80+
Args:
81+
part: The A2A message part to log.
82+
83+
Returns:
84+
A string representation of the part.
85+
"""
86+
part_content = ""
87+
if _is_a2a_text_part(part.root):
88+
part_content = f"TextPart: {part.root.text[:100]}" + (
89+
"..." if len(part.root.text) > 100 else ""
90+
)
91+
elif _is_a2a_data_part(part.root):
92+
# For data parts, show the data keys but exclude large values
93+
data_summary = {
94+
k: (
95+
f"<{type(v).__name__}>"
96+
if isinstance(v, (dict, list)) and len(str(v)) > 100
97+
else v
98+
)
99+
for k, v in part.root.data.items()
100+
}
101+
part_content = f"DataPart: {json.dumps(data_summary, indent=2)}"
102+
else:
103+
part_content = (
104+
f"{type(part.root).__name__}:"
105+
f" {part.model_dump_json(exclude_none=True, exclude=_EXCLUDED_PART_FIELD)}"
106+
)
107+
108+
# Add part metadata if it exists
109+
if hasattr(part.root, "metadata") and part.root.metadata:
110+
metadata_str = json.dumps(part.root.metadata, indent=2).replace(
111+
"\n", "\n "
112+
)
113+
part_content += f"\n Part Metadata: {metadata_str}"
114+
115+
return part_content
116+
117+
118+
def build_a2a_request_log(req: SendMessageRequest) -> str:
119+
"""Builds a structured log representation of an A2A request.
120+
121+
Args:
122+
req: The A2A SendMessageRequest to log.
123+
124+
Returns:
125+
A formatted string representation of the request.
126+
"""
127+
# Message parts logs
128+
message_parts_logs = []
129+
if req.params.message.parts:
130+
for i, part in enumerate(req.params.message.parts):
131+
part_log = build_message_part_log(part)
132+
# Replace any internal newlines with indented newlines to maintain formatting
133+
part_log_formatted = part_log.replace("\n", "\n ")
134+
message_parts_logs.append(f"Part {i}: {part_log_formatted}")
135+
136+
# Configuration logs
137+
config_log = "None"
138+
if req.params.configuration:
139+
config_data = {
140+
"acceptedOutputModes": req.params.configuration.acceptedOutputModes,
141+
"blocking": req.params.configuration.blocking,
142+
"historyLength": req.params.configuration.historyLength,
143+
"pushNotificationConfig": bool(
144+
req.params.configuration.pushNotificationConfig
145+
),
146+
}
147+
config_log = json.dumps(config_data, indent=2)
148+
149+
# Build message metadata section
150+
message_metadata_section = ""
151+
if req.params.message.metadata:
152+
message_metadata_section = f"""
153+
Metadata:
154+
{json.dumps(req.params.message.metadata, indent=2).replace(chr(10), chr(10) + ' ')}"""
155+
156+
# Build optional sections
157+
optional_sections = []
158+
159+
if req.params.metadata:
160+
optional_sections.append(
161+
f"""-----------------------------------------------------------
162+
Metadata:
163+
{json.dumps(req.params.metadata, indent=2)}"""
164+
)
165+
166+
optional_sections_str = _NEW_LINE.join(optional_sections)
167+
168+
return f"""
169+
A2A Request:
170+
-----------------------------------------------------------
171+
Request ID: {req.id}
172+
Method: {req.method}
173+
JSON-RPC: {req.jsonrpc}
174+
-----------------------------------------------------------
175+
Message:
176+
ID: {req.params.message.messageId}
177+
Role: {req.params.message.role}
178+
Task ID: {req.params.message.taskId}
179+
Context ID: {req.params.message.contextId}{message_metadata_section}
180+
-----------------------------------------------------------
181+
Message Parts:
182+
{_NEW_LINE.join(message_parts_logs) if message_parts_logs else "No parts"}
183+
-----------------------------------------------------------
184+
Configuration:
185+
{config_log}
186+
{optional_sections_str}
187+
-----------------------------------------------------------
188+
"""
189+
190+
191+
def build_a2a_response_log(resp: SendMessageResponse) -> str:
192+
"""Builds a structured log representation of an A2A response.
193+
194+
Args:
195+
resp: The A2A SendMessageResponse to log.
196+
197+
Returns:
198+
A formatted string representation of the response.
199+
"""
200+
# Handle error responses
201+
if hasattr(resp.root, "error"):
202+
return f"""
203+
A2A Response:
204+
-----------------------------------------------------------
205+
Type: ERROR
206+
Error Code: {resp.root.error.code}
207+
Error Message: {resp.root.error.message}
208+
Error Data: {json.dumps(resp.root.error.data, indent=2) if resp.root.error.data else "None"}
209+
-----------------------------------------------------------
210+
Response ID: {resp.root.id}
211+
JSON-RPC: {resp.root.jsonrpc}
212+
-----------------------------------------------------------
213+
"""
214+
215+
# Handle success responses
216+
result = resp.root.result
217+
result_type = type(result).__name__
218+
219+
# Build result details based on type
220+
result_details = []
221+
222+
if _is_a2a_task(result):
223+
result_details.extend([
224+
f"Task ID: {result.id}",
225+
f"Context ID: {result.contextId}",
226+
f"Status State: {result.status.state}",
227+
f"Status Timestamp: {result.status.timestamp}",
228+
f"History Length: {len(result.history) if result.history else 0}",
229+
f"Artifacts Count: {len(result.artifacts) if result.artifacts else 0}",
230+
])
231+
232+
# Add task metadata if it exists
233+
if result.metadata:
234+
result_details.append("Task Metadata:")
235+
metadata_formatted = json.dumps(result.metadata, indent=2).replace(
236+
"\n", "\n "
237+
)
238+
result_details.append(f" {metadata_formatted}")
239+
240+
elif _is_a2a_message(result):
241+
result_details.extend([
242+
f"Message ID: {result.messageId}",
243+
f"Role: {result.role}",
244+
f"Task ID: {result.taskId}",
245+
f"Context ID: {result.contextId}",
246+
])
247+
248+
# Add message parts
249+
if result.parts:
250+
result_details.append("Message Parts:")
251+
for i, part in enumerate(result.parts):
252+
part_log = build_message_part_log(part)
253+
# Replace any internal newlines with indented newlines to maintain formatting
254+
part_log_formatted = part_log.replace("\n", "\n ")
255+
result_details.append(f" Part {i}: {part_log_formatted}")
256+
257+
# Add metadata if it exists
258+
if result.metadata:
259+
result_details.append("Metadata:")
260+
metadata_formatted = json.dumps(result.metadata, indent=2).replace(
261+
"\n", "\n "
262+
)
263+
result_details.append(f" {metadata_formatted}")
264+
265+
else:
266+
# Handle other result types by showing their JSON representation
267+
if hasattr(result, "model_dump_json"):
268+
try:
269+
result_json = result.model_dump_json()
270+
result_details.append(f"JSON Data: {result_json}")
271+
except Exception:
272+
result_details.append("JSON Data: <unable to serialize>")
273+
274+
# Build status message section
275+
status_message_section = "None"
276+
if _is_a2a_task(result) and result.status.message:
277+
status_parts_logs = []
278+
if result.status.message.parts:
279+
for i, part in enumerate(result.status.message.parts):
280+
part_log = build_message_part_log(part)
281+
# Replace any internal newlines with indented newlines to maintain formatting
282+
part_log_formatted = part_log.replace("\n", "\n ")
283+
status_parts_logs.append(f"Part {i}: {part_log_formatted}")
284+
285+
# Build status message metadata section
286+
status_metadata_section = ""
287+
if result.status.message.metadata:
288+
status_metadata_section = f"""
289+
Metadata:
290+
{json.dumps(result.status.message.metadata, indent=2)}"""
291+
292+
status_message_section = f"""ID: {result.status.message.messageId}
293+
Role: {result.status.message.role}
294+
Task ID: {result.status.message.taskId}
295+
Context ID: {result.status.message.contextId}
296+
Message Parts:
297+
{_NEW_LINE.join(status_parts_logs) if status_parts_logs else "No parts"}{status_metadata_section}"""
298+
299+
# Build history section
300+
history_section = "No history"
301+
if _is_a2a_task(result) and result.history:
302+
history_logs = []
303+
for i, message in enumerate(result.history):
304+
message_parts_logs = []
305+
if message.parts:
306+
for j, part in enumerate(message.parts):
307+
part_log = build_message_part_log(part)
308+
# Replace any internal newlines with indented newlines to maintain formatting
309+
part_log_formatted = part_log.replace("\n", "\n ")
310+
message_parts_logs.append(f" Part {j}: {part_log_formatted}")
311+
312+
# Build message metadata section
313+
message_metadata_section = ""
314+
if message.metadata:
315+
message_metadata_section = f"""
316+
Metadata:
317+
{json.dumps(message.metadata, indent=2).replace(chr(10), chr(10) + ' ')}"""
318+
319+
history_logs.append(
320+
f"""Message {i + 1}:
321+
ID: {message.messageId}
322+
Role: {message.role}
323+
Task ID: {message.taskId}
324+
Context ID: {message.contextId}
325+
Message Parts:
326+
{_NEW_LINE.join(message_parts_logs) if message_parts_logs else " No parts"}{message_metadata_section}"""
327+
)
328+
329+
history_section = _NEW_LINE.join(history_logs)
330+
331+
return f"""
332+
A2A Response:
333+
-----------------------------------------------------------
334+
Type: SUCCESS
335+
Result Type: {result_type}
336+
-----------------------------------------------------------
337+
Result Details:
338+
{_NEW_LINE.join(result_details)}
339+
-----------------------------------------------------------
340+
Status Message:
341+
{status_message_section}
342+
-----------------------------------------------------------
343+
History:
344+
{history_section}
345+
-----------------------------------------------------------
346+
Response ID: {resp.root.id}
347+
JSON-RPC: {resp.root.jsonrpc}
348+
-----------------------------------------------------------
349+
"""

tests/unittests/a2a/logs/__init__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
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+
# http://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.

0 commit comments

Comments
 (0)