Skip to content

Commit daab364

Browse files
seanzhougooglecopybara-github
authored andcommitted
chore: Add a2a log utils for formatting a2a reqeust/response logs
PiperOrigin-RevId: 774642581
1 parent 7c670f6 commit daab364

File tree

10 files changed

+1240
-215
lines changed

10 files changed

+1240
-215
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: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
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 build_message_part_log(part: A2APart) -> str:
46+
"""Builds a log representation of an A2A message part.
47+
48+
Args:
49+
part: The A2A message part to log.
50+
51+
Returns:
52+
A string representation of the part.
53+
"""
54+
if isinstance(part.root, A2ATextPart):
55+
return f"TextPart: {part.root.text[:100]}" + (
56+
"..." if len(part.root.text) > 100 else ""
57+
)
58+
elif isinstance(part.root, A2ADataPart):
59+
# For data parts, show the data keys but exclude large values
60+
data_summary = {
61+
k: (
62+
f"<{type(v).__name__}>"
63+
if isinstance(v, (dict, list)) and len(str(v)) > 100
64+
else v
65+
)
66+
for k, v in part.root.data.items()
67+
}
68+
return f"DataPart: {json.dumps(data_summary, indent=2)}"
69+
else:
70+
return (
71+
f"{type(part.root).__name__}:"
72+
f" {part.model_dump_json(exclude_none=True, exclude=_EXCLUDED_PART_FIELD)}"
73+
)
74+
75+
76+
def build_a2a_request_log(req: SendMessageRequest) -> str:
77+
"""Builds a structured log representation of an A2A request.
78+
79+
Args:
80+
req: The A2A SendMessageRequest to log.
81+
82+
Returns:
83+
A formatted string representation of the request.
84+
"""
85+
# Message parts logs
86+
message_parts_logs = []
87+
if req.params.message.parts:
88+
for i, part in enumerate(req.params.message.parts):
89+
message_parts_logs.append(f"Part {i}: {build_message_part_log(part)}")
90+
91+
# Configuration logs
92+
config_log = "None"
93+
if req.params.configuration:
94+
config_data = {
95+
"acceptedOutputModes": req.params.configuration.acceptedOutputModes,
96+
"blocking": req.params.configuration.blocking,
97+
"historyLength": req.params.configuration.historyLength,
98+
"pushNotificationConfig": bool(
99+
req.params.configuration.pushNotificationConfig
100+
),
101+
}
102+
config_log = json.dumps(config_data, indent=2)
103+
104+
return f"""
105+
A2A Request:
106+
-----------------------------------------------------------
107+
Request ID: {req.id}
108+
Method: {req.method}
109+
JSON-RPC: {req.jsonrpc}
110+
-----------------------------------------------------------
111+
Message:
112+
ID: {req.params.message.messageId}
113+
Role: {req.params.message.role}
114+
Task ID: {req.params.message.taskId}
115+
Context ID: {req.params.message.contextId}
116+
-----------------------------------------------------------
117+
Message Parts:
118+
{_NEW_LINE.join(message_parts_logs) if message_parts_logs else "No parts"}
119+
-----------------------------------------------------------
120+
Configuration:
121+
{config_log}
122+
-----------------------------------------------------------
123+
Metadata:
124+
{json.dumps(req.params.metadata, indent=2) if req.params.metadata else "None"}
125+
-----------------------------------------------------------
126+
"""
127+
128+
129+
def build_a2a_response_log(resp: SendMessageResponse) -> str:
130+
"""Builds a structured log representation of an A2A response.
131+
132+
Args:
133+
resp: The A2A SendMessageResponse to log.
134+
135+
Returns:
136+
A formatted string representation of the response.
137+
"""
138+
# Handle error responses
139+
if hasattr(resp.root, "error"):
140+
return f"""
141+
A2A Response:
142+
-----------------------------------------------------------
143+
Type: ERROR
144+
Error Code: {resp.root.error.code}
145+
Error Message: {resp.root.error.message}
146+
Error Data: {json.dumps(resp.root.error.data, indent=2) if resp.root.error.data else "None"}
147+
-----------------------------------------------------------
148+
Response ID: {resp.root.id}
149+
JSON-RPC: {resp.root.jsonrpc}
150+
-----------------------------------------------------------
151+
"""
152+
153+
# Handle success responses
154+
result = resp.root.result
155+
result_type = type(result).__name__
156+
157+
# Build result details based on type
158+
result_details = []
159+
160+
if isinstance(result, A2ATask):
161+
result_details.extend([
162+
f"Task ID: {result.id}",
163+
f"Context ID: {result.contextId}",
164+
f"Status State: {result.status.state}",
165+
f"Status Timestamp: {result.status.timestamp}",
166+
f"History Length: {len(result.history) if result.history else 0}",
167+
f"Artifacts Count: {len(result.artifacts) if result.artifacts else 0}",
168+
])
169+
170+
# Add status message if present
171+
if result.status.message:
172+
status_parts_logs = []
173+
if result.status.message.parts:
174+
for i, part in enumerate(result.status.message.parts):
175+
status_parts_logs.append(
176+
f" Part {i}: {build_message_part_log(part)}"
177+
)
178+
result_details.extend([
179+
f"Status Message ID: {result.status.message.messageId}",
180+
f"Status Message Role: {result.status.message.role}",
181+
"Status Message Parts:",
182+
*status_parts_logs,
183+
])
184+
185+
elif isinstance(result, A2AMessage):
186+
result_details.extend([
187+
f"Message ID: {result.messageId}",
188+
f"Role: {result.role}",
189+
f"Task ID: {result.taskId}",
190+
f"Context ID: {result.contextId}",
191+
])
192+
193+
# Add message parts
194+
if result.parts:
195+
result_details.append("Message Parts:")
196+
for i, part in enumerate(result.parts):
197+
result_details.append(f" Part {i}: {build_message_part_log(part)}")
198+
199+
return f"""
200+
A2A Response:
201+
-----------------------------------------------------------
202+
Type: SUCCESS
203+
Result Type: {result_type}
204+
-----------------------------------------------------------
205+
Result Details:
206+
{_NEW_LINE.join(result_details)}
207+
-----------------------------------------------------------
208+
Response ID: {resp.root.id}
209+
JSON-RPC: {resp.root.jsonrpc}
210+
-----------------------------------------------------------
211+
Raw response summary:
212+
{result.model_dump_json(exclude_none=True, exclude=_EXCLUDED_PART_FIELD) if hasattr(result, 'model_dump_json') else str(result)}
213+
-----------------------------------------------------------
214+
"""

src/google/adk/flows/llm_flows/contents.py

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616

1717
import copy
1818
from typing import AsyncGenerator
19-
from typing import Generator
2019
from typing import Optional
2120

2221
from google.genai import types
@@ -219,7 +218,7 @@ def _get_contents(
219218
# skip auth event
220219
continue
221220
filtered_events.append(
222-
_convert_foreign_event(event)
221+
_convert_event_from_user_perspective(event)
223222
if _is_other_agent_reply(agent_name, event)
224223
else event
225224
)
@@ -247,15 +246,18 @@ def _is_other_agent_reply(current_agent_name: str, event: Event) -> bool:
247246
)
248247

249248

250-
def _convert_foreign_event(event: Event) -> Event:
251-
"""Converts an event authored by another agent as a user-content event.
249+
def _convert_event_from_user_perspective(
250+
event: Event, current_agent: str = None
251+
) -> Event:
252+
"""Converts an event generated by an agent as a user-content event.
252253
253-
This is to provide another agent's output as context to the current agent, so
254+
This is to provide agent's output as context to the current agent, so
254255
that current agent can continue to respond, such as summarizing previous
255256
agent's reply, etc.
256257
257258
Args:
258259
event: The event to convert.
260+
current_agent: The name of the current agent.
259261
260262
Returns:
261263
The converted event.
@@ -270,14 +272,20 @@ def _convert_foreign_event(event: Event) -> Event:
270272
for part in event.content.parts:
271273
if part.text:
272274
content.parts.append(
273-
types.Part(text=f'[{event.author}] said: {part.text}')
275+
types.Part(
276+
text=(
277+
f'[{event.author if event.author != current_agent else "You"}]'
278+
f' said: {part.text}'
279+
)
280+
)
274281
)
275282
elif part.function_call:
276283
content.parts.append(
277284
types.Part(
278285
text=(
279-
f'[{event.author}] called tool `{part.function_call.name}`'
280-
f' with parameters: {part.function_call.args}'
286+
f'[{event.author if event.author != current_agent else "You"}]'
287+
f' called tool `{part.function_call.name}` with parameters:'
288+
f' {part.function_call.args}'
281289
)
282290
)
283291
)
@@ -286,8 +294,9 @@ def _convert_foreign_event(event: Event) -> Event:
286294
content.parts.append(
287295
types.Part(
288296
text=(
289-
f'[{event.author}] `{part.function_response.name}` tool'
290-
f' returned result: {part.function_response.response}'
297+
f'[{event.author if event.author != current_agent else "You"}]'
298+
f' `{part.function_response.name}` tool returned result:'
299+
f' {part.function_response.response}'
291300
)
292301
)
293302
)

src/google/adk/flows/llm_flows/functions.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,3 +519,34 @@ def merge_parallel_function_response_events(
519519
# Use the base_event as the timestamp
520520
merged_event.timestamp = base_event.timestamp
521521
return merged_event
522+
523+
524+
def find_function_call_event_if_last_event_is_function_response(
525+
events: list[Event],
526+
) -> Optional[Event]:
527+
if not events:
528+
return None
529+
530+
last_event = events[-1]
531+
if (
532+
last_event.content
533+
and last_event.content.parts
534+
and any(part.function_response for part in last_event.content.parts)
535+
):
536+
537+
function_call_id = next(
538+
part.function_response.id
539+
for part in last_event.content.parts
540+
if part.function_response
541+
)
542+
for i in range(len(events) - 2, -1, -1):
543+
event = events[i]
544+
# looking for the system long running request euc function call
545+
function_calls = event.get_function_calls()
546+
if not function_calls:
547+
continue
548+
549+
for function_call in function_calls:
550+
if function_call.id == function_call_id:
551+
return event
552+
return None

src/google/adk/runners.py

Lines changed: 3 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
from .auth.credential_service.base_credential_service import BaseCredentialService
3737
from .code_executors.built_in_code_executor import BuiltInCodeExecutor
3838
from .events.event import Event
39+
from .flows.llm_flows.functions import find_function_call_event_if_last_event_is_function_response
3940
from .memory.base_memory_service import BaseMemoryService
4041
from .memory.in_memory_memory_service import InMemoryMemoryService
4142
from .platform.thread import create_thread
@@ -354,8 +355,8 @@ def _find_agent_to_run(
354355
# the agent that returned the corressponding function call regardless the
355356
# type of the agent. e.g. a remote a2a agent may surface a credential
356357
# request as a special long running function tool call.
357-
event = _find_function_call_event_if_last_event_is_function_response(
358-
session
358+
event = find_function_call_event_if_last_event_is_function_response(
359+
session.events
359360
)
360361
if event and event.author:
361362
return root_agent.find_agent(event.author)
@@ -538,35 +539,3 @@ def __init__(self, agent: BaseAgent, *, app_name: str = 'InMemoryRunner'):
538539
session_service=self._in_memory_session_service,
539540
memory_service=InMemoryMemoryService(),
540541
)
541-
542-
543-
def _find_function_call_event_if_last_event_is_function_response(
544-
session: Session,
545-
) -> Optional[Event]:
546-
events = session.events
547-
if not events:
548-
return None
549-
550-
last_event = events[-1]
551-
if (
552-
last_event.content
553-
and last_event.content.parts
554-
and any(part.function_response for part in last_event.content.parts)
555-
):
556-
557-
function_call_id = next(
558-
part.function_response.id
559-
for part in last_event.content.parts
560-
if part.function_response
561-
)
562-
for i in range(len(events) - 2, -1, -1):
563-
event = events[i]
564-
# looking for the system long running request euc function call
565-
function_calls = event.get_function_calls()
566-
if not function_calls:
567-
continue
568-
569-
for function_call in function_calls:
570-
if function_call.id == function_call_id:
571-
return event
572-
return None

0 commit comments

Comments
 (0)