Skip to content

Commit e112d70

Browse files
authored
feat(adk): Add support for nested subagents tool calls (#131)
1 parent 1099651 commit e112d70

5 files changed

Lines changed: 243 additions & 1 deletion

File tree

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
interactions:
2+
- request:
3+
body: '{"contents": [{"parts": [{"text": "What''s the weather in San Francisco?"}],
4+
"role": "user"}], "systemInstruction": {"parts": [{"text": "You are a helpful
5+
weather assistant. Use the get_weather tool to answer questions about weather.\n\nYou
6+
are an agent. Your internal name is \"weather_agent\"."}], "role": "user"},
7+
"tools": [{"functionDeclarations": [{"description": "Get the weather for a location.",
8+
"name": "get_weather", "parameters": {"properties": {"location": {"type": "STRING"}},
9+
"required": ["location"], "type": "OBJECT"}}]}], "generationConfig": {}}'
10+
headers:
11+
accept:
12+
- '*/*'
13+
accept-encoding:
14+
- gzip, deflate
15+
connection:
16+
- keep-alive
17+
content-length:
18+
- '561'
19+
content-type:
20+
- application/json
21+
host:
22+
- generativelanguage.googleapis.com
23+
user-agent:
24+
- google-genai-sdk/1.31.0 gl-python/3.9.21 google-adk/1.14.1 gl-python/3.9.21
25+
x-goog-api-client:
26+
- google-genai-sdk/1.31.0 gl-python/3.9.21 google-adk/1.14.1 gl-python/3.9.21
27+
method: POST
28+
uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent
29+
response:
30+
body:
31+
string: !!binary |
32+
H4sIAAAAAAAC/61SXU+DMBR9768gfR4LMofG100TP4hTyWJijLnChTWWFttuy1z23y1fG0x9ExJS
33+
7jk9596ebonj0BhEwhIwqOmF82IrjrOtviUmhUFhLNCWbLEAZQ7c+tl21paSLkVsmBQT4Ly3ucEF
34+
5GjrNEPztkYwC1R0cEwClelfNluEyxhK+VLiCYRzpUDETMeSHnF35K+/w/r1YEyV5FVfuUyQt2K7
35+
lkBTJphePCLoxju6n+37prDK7mRWKPletu2eDwPf98bBaeD59h2fjND1AtKaV7Z0qSHDEA3YAGA/
36+
LLUieWEi+YFiIpdVAOOz2qiTVw8PGthIA7yPjAY/VPXUejLejbGTsB0fODObcsbo8jnqZGP1e021
37+
Z0Q6R3nc4j+ZBX0v0iRThzVHpZsbkWFuc3L9oeemHPSiEqQKdSGFxuuk5Ez9NITwFqfhav1p9EzG
38+
XzebB4+SHfkGVtQS/hUDAAA=
39+
headers:
40+
Alt-Svc:
41+
- h3=":443"; ma=2592000,h3-29=":443"; ma=2592000
42+
Content-Encoding:
43+
- gzip
44+
Content-Type:
45+
- application/json; charset=UTF-8
46+
Date:
47+
- Thu, 18 Sep 2025 20:09:51 GMT
48+
Server:
49+
- scaffolding on HTTPServer2
50+
Server-Timing:
51+
- gfet4t7; dur=530
52+
Transfer-Encoding:
53+
- chunked
54+
Vary:
55+
- Origin
56+
- X-Origin
57+
- Referer
58+
X-Content-Type-Options:
59+
- nosniff
60+
X-Frame-Options:
61+
- SAMEORIGIN
62+
X-XSS-Protection:
63+
- '0'
64+
status:
65+
code: 200
66+
message: OK
67+
- request:
68+
body: '{"contents": [{"parts": [{"text": "What''s the weather in San Francisco?"}],
69+
"role": "user"}, {"parts": [{"functionCall": {"args": {"location": "San Francisco"},
70+
"name": "get_weather"}}], "role": "model"}, {"parts": [{"functionResponse":
71+
{"name": "get_weather", "response": {"location": "San Francisco", "temperature":
72+
"72\u00b0F", "condition": "sunny", "humidity": "45%", "wind": "5 mph NW"}}}],
73+
"role": "user"}], "systemInstruction": {"parts": [{"text": "You are a helpful
74+
weather assistant. Use the get_weather tool to answer questions about weather.\n\nYou
75+
are an agent. Your internal name is \"weather_agent\"."}], "role": "user"},
76+
"tools": [{"functionDeclarations": [{"description": "Get the weather for a location.",
77+
"name": "get_weather", "parameters": {"properties": {"location": {"type": "STRING"}},
78+
"required": ["location"], "type": "OBJECT"}}]}], "generationConfig": {}}'
79+
headers:
80+
accept:
81+
- '*/*'
82+
accept-encoding:
83+
- gzip, deflate
84+
connection:
85+
- keep-alive
86+
content-length:
87+
- '881'
88+
content-type:
89+
- application/json
90+
host:
91+
- generativelanguage.googleapis.com
92+
user-agent:
93+
- google-genai-sdk/1.31.0 gl-python/3.9.21 google-adk/1.14.1 gl-python/3.9.21
94+
x-goog-api-client:
95+
- google-genai-sdk/1.31.0 gl-python/3.9.21 google-adk/1.14.1 gl-python/3.9.21
96+
method: POST
97+
uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent
98+
response:
99+
body:
100+
string: !!binary |
101+
H4sIAAAAAAAC/61R0U7bQBB891esTuobiS5OHELfqqaRUIEisACprdA2Xsen2nfmbl2IovwT38CX
102+
cefUwaGv9YO12pnbGc1sIgCxRJ2pDJmc+Ajf/QZg0/4DZjSTZg90K7+s0fIbd/dterOnMD2FRyIt
103+
CB4JuSALSsM1alhY1EvllgaUA9dovYZHxQUgMFU1WeTGEpgcjuOX58UQwomiqVSmeB2eTJIP4B0D
104+
h9PKD36XQFUXcHE7/KFFz8h2P/88erNvTUnBW2UyKjv6tiOIXGnliitCZ3SgXaffLsUexT+rM7Oq
105+
rfkVEhjIoZQymU1HUo6n8iSeTpKTWRx14q2saByu6JwYfci4j1L4I1XNqflN+rNp2pBnk51Qr5MD
106+
fNzhbBjLA2g0mh39c9fNvaoq+2X1evQBYOlTbYv6cpeKXkh8aKtLKeqF+d7kfxIbvxOL/paz6+uG
107+
rFO7YlZU+aoG8VAO8hJd0V4UllxttKPTLHDmcX6Op+nXi3v19MDu8j6npPkkRbSNXgFas32N/AIA
108+
AA==
109+
headers:
110+
Alt-Svc:
111+
- h3=":443"; ma=2592000,h3-29=":443"; ma=2592000
112+
Content-Encoding:
113+
- gzip
114+
Content-Type:
115+
- application/json; charset=UTF-8
116+
Date:
117+
- Thu, 18 Sep 2025 20:09:52 GMT
118+
Server:
119+
- scaffolding on HTTPServer2
120+
Server-Timing:
121+
- gfet4t7; dur=612
122+
Transfer-Encoding:
123+
- chunked
124+
Vary:
125+
- Origin
126+
- X-Origin
127+
- Referer
128+
X-Content-Type-Options:
129+
- nosniff
130+
X-Frame-Options:
131+
- SAMEORIGIN
132+
X-XSS-Protection:
133+
- '0'
134+
status:
135+
code: 200
136+
message: OK
137+
version: 1

py/src/braintrust/integrations/adk/integration.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
McpToolPatcher,
1111
RunnerRunSyncPatcher,
1212
ThreadBridgePatcher,
13+
ToolCallAsyncPatcher,
1314
)
1415

1516

@@ -27,5 +28,6 @@ class ADKIntegration(BaseIntegration):
2728
AgentRunAsyncPatcher,
2829
RunnerRunSyncPatcher,
2930
FlowRunAsyncPatcher,
31+
ToolCallAsyncPatcher,
3032
McpToolPatcher,
3133
)

py/src/braintrust/integrations/adk/patchers.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
_mcp_tool_run_async_wrapper_async,
1313
_runner_run_async_wrapper,
1414
_runner_run_wrapper,
15+
_tool_call_async_wrapper,
1516
)
1617

1718

@@ -89,6 +90,20 @@ class FlowRunAsyncPatcher(CompositeFunctionWrapperPatcher):
8990
sub_patchers = (_FlowRunAsyncSubPatcher, _FlowCallLlmAsyncSubPatcher)
9091

9192

93+
# ---------------------------------------------------------------------------
94+
# Tool patcher
95+
# ---------------------------------------------------------------------------
96+
97+
98+
class ToolCallAsyncPatcher(FunctionWrapperPatcher):
99+
"""Patch ADK's central async tool execution helper for tracing."""
100+
101+
name = "adk.tool.call_async"
102+
target_module = "google.adk.flows.llm_flows.functions"
103+
target_path = "__call_tool_async"
104+
wrapper = _tool_call_async_wrapper
105+
106+
92107
# ---------------------------------------------------------------------------
93108
# Thread-bridge patchers
94109
# ---------------------------------------------------------------------------

py/src/braintrust/integrations/adk/test_adk.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414

1515
ADK_VERSION = tuple(int(x) for x in pkg_version("google-adk").split(".")[:3])
16-
from google.adk.agents import LlmAgent
16+
from google.adk.agents import LlmAgent, ParallelAgent, SequentialAgent
1717
from google.adk.runners import Runner
1818
from google.adk.sessions import InMemorySessionService
1919
from google.genai import types
@@ -226,6 +226,69 @@ def get_weather(location: str):
226226
assert "72" in response_output, "Response doesn't mention temperature"
227227

228228

229+
@pytest.mark.vcr
230+
@pytest.mark.asyncio
231+
async def test_adk_nested_subagent_tool_calls_are_traced(memory_logger):
232+
assert not memory_logger.pop()
233+
234+
def get_weather(location: str):
235+
"""Get the weather for a location."""
236+
return {
237+
"location": location,
238+
"temperature": "72°F",
239+
"condition": "sunny",
240+
}
241+
242+
leaf_agent = Agent(
243+
name="weather_agent",
244+
model="gemini-2.0-flash",
245+
instruction="You are a helpful weather assistant. Use the get_weather tool to answer questions about weather.",
246+
tools=[get_weather],
247+
)
248+
agent = SequentialAgent(
249+
name="root_agent",
250+
sub_agents=[
251+
ParallelAgent(
252+
name="parallel_weather_agent",
253+
sub_agents=[leaf_agent],
254+
)
255+
],
256+
)
257+
258+
app_name = "nested_weather_app"
259+
user_id = "test-user"
260+
session_id = "test-session-nested"
261+
262+
session_service = InMemorySessionService()
263+
await session_service.create_session(app_name=app_name, user_id=user_id, session_id=session_id)
264+
265+
runner = Runner(agent=agent, app_name=app_name, session_service=session_service)
266+
user_msg = types.Content(role="user", parts=[types.Part(text="What's the weather in San Francisco?")])
267+
268+
responses = []
269+
async for event in runner.run_async(user_id=user_id, session_id=session_id, new_message=user_msg):
270+
if event.is_final_response():
271+
responses.append(event)
272+
273+
assert responses
274+
assert responses[0].content
275+
response_text = responses[0].content.parts[0].text
276+
assert "san francisco" in response_text.lower()
277+
278+
spans = memory_logger.pop()
279+
280+
tool_spans = [row for row in spans if row["span_attributes"]["type"] == "tool"]
281+
assert len(tool_spans) == 1, (
282+
f"Expected one tool span, got {[row['span_attributes']['name'] for row in tool_spans]}"
283+
)
284+
285+
tool_span = tool_spans[0]
286+
assert tool_span["span_attributes"]["name"] == "tool [get_weather]"
287+
assert tool_span["input"]["arguments"] == {"location": "San Francisco"}
288+
assert tool_span["output"]["location"] == "San Francisco"
289+
assert tool_span["output"]["temperature"] == "72°F"
290+
291+
229292
@pytest.mark.vcr
230293
@pytest.mark.asyncio
231294
async def test_adk_max_tokens_captures_content(memory_logger):

py/src/braintrust/integrations/adk/tracing.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,31 @@ async def _trace():
548548
yield event
549549

550550

551+
async def _tool_call_async_wrapper(wrapped: Any, instance: Any, args: Any, kwargs: Any):
552+
tool = args[0] if len(args) > 0 else kwargs.get("tool")
553+
tool_args = args[1] if len(args) > 1 else kwargs.get("args", {})
554+
555+
# MCP tools already have a dedicated wrapper. Skip here to avoid duplicate tool spans.
556+
if tool is not None and getattr(tool.__class__, "__module__", "").startswith("google.adk.tools.mcp_tool"):
557+
return await wrapped(*args, **kwargs)
558+
559+
tool_name = getattr(tool, "name", tool.__class__.__name__ if tool is not None else "unknown")
560+
561+
with start_span(
562+
name=f"tool [{tool_name}]",
563+
type=SpanTypeAttribute.TOOL,
564+
input={"tool_name": tool_name, "arguments": bt_safe_deep_copy(tool_args)},
565+
metadata={"tool_class": tool.__class__.__name__ if tool is not None else None},
566+
) as tool_span:
567+
try:
568+
result = await wrapped(*args, **kwargs)
569+
tool_span.log(output=result)
570+
return result
571+
except Exception as e:
572+
tool_span.log(error=str(e))
573+
raise
574+
575+
551576
async def _mcp_tool_run_async_wrapper_async(wrapped: Any, instance: Any, args: Any, kwargs: Any):
552577
# Extract tool information
553578
tool_name = instance.name

0 commit comments

Comments
 (0)