From 4c71df65eedc5d22593bc440ced5af6e811dd77f Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko Date: Wed, 19 Nov 2025 17:17:51 +0100 Subject: [PATCH 1/5] refactor: Clean up imports and enhance AgentTool with event callback support - Removed unused imports in `experiment.py` and `run_experiment.py`. - Updated `AgentTool` class in `agent_tool.py` to include an optional `event_callback` parameter for handling events emitted by the child agent, supporting both synchronous and asynchronous functions. # Conflicts: # src/google/adk/tools/agent_tool.py --- contributing/samples/gepa/experiment.py | 1 - contributing/samples/gepa/run_experiment.py | 1 - src/google/adk/tools/agent_tool.py | 18 ++ .../tools/test_agent_tool_event_callback.py | 294 ++++++++++++++++++ 4 files changed, 312 insertions(+), 2 deletions(-) create mode 100644 tests/unittests/tools/test_agent_tool_event_callback.py diff --git a/contributing/samples/gepa/experiment.py b/contributing/samples/gepa/experiment.py index 2f5d03a772..f68b349d9c 100644 --- a/contributing/samples/gepa/experiment.py +++ b/contributing/samples/gepa/experiment.py @@ -43,7 +43,6 @@ from tau_bench.types import EnvRunResult from tau_bench.types import RunConfig import tau_bench_agent as tau_bench_agent_lib - import utils diff --git a/contributing/samples/gepa/run_experiment.py b/contributing/samples/gepa/run_experiment.py index cfd850b3a3..1bc4ee58c8 100644 --- a/contributing/samples/gepa/run_experiment.py +++ b/contributing/samples/gepa/run_experiment.py @@ -25,7 +25,6 @@ from absl import flags import experiment from google.genai import types - import utils _OUTPUT_DIR = flags.DEFINE_string( diff --git a/src/google/adk/tools/agent_tool.py b/src/google/adk/tools/agent_tool.py index 46d8616619..8495fefca5 100644 --- a/src/google/adk/tools/agent_tool.py +++ b/src/google/adk/tools/agent_tool.py @@ -14,8 +14,12 @@ from __future__ import annotations +import inspect from typing import Any +from typing import Awaitable +from typing import Callable from typing import TYPE_CHECKING +from typing import Union from google.genai import types from pydantic import model_validator @@ -33,6 +37,7 @@ if TYPE_CHECKING: from ..agents.base_agent import BaseAgent + from ..events.event import Event class AgentTool(BaseTool): @@ -49,6 +54,8 @@ class AgentTool(BaseTool): to the agent's runner. When True (default), the agent will inherit all plugins from its parent. Set to False to run the agent with an isolated plugin environment. + event_callback: Optional callback invoked for each event emitted by the + child agent. Can be either a synchronous or asynchronous function. """ def __init__( @@ -57,9 +64,13 @@ def __init__( skip_summarization: bool = False, *, include_plugins: bool = True, + event_callback: Union[ + Callable[[Event], None], Callable[[Event], Awaitable[None]], None + ] = None ): self.agent = agent self.skip_summarization: bool = skip_summarization + self.event_callback = event_callback self.include_plugins = include_plugins super().__init__(name=agent.name, description=agent.description) @@ -182,6 +193,13 @@ async def run_async( if event.content: last_content = event.content + # Invoke user-provided event callback if present. + if self.event_callback: + if inspect.iscoroutinefunction(self.event_callback): + await self.event_callback(event) + else: + self.event_callback(event) + # Clean up runner resources (especially MCP sessions) # to avoid "Attempted to exit cancel scope in a different task" errors await runner.close() diff --git a/tests/unittests/tools/test_agent_tool_event_callback.py b/tests/unittests/tools/test_agent_tool_event_callback.py new file mode 100644 index 0000000000..60d05ecfe9 --- /dev/null +++ b/tests/unittests/tools/test_agent_tool_event_callback.py @@ -0,0 +1,294 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for AgentTool event_callback functionality.""" + +from google.adk.agents.llm_agent import Agent +from google.adk.events.event import Event +from google.adk.tools.agent_tool import AgentTool +from google.genai.types import Part +from pytest import mark + +from .. import testing_utils + + +@mark.asyncio +async def test_event_callback_sync_invocation(): + """Test that synchronous event callbacks are invoked correctly.""" + captured_events = [] + + def sync_callback(event: Event) -> None: + captured_events.append(event) + + function_call = Part.from_function_call( + name='tool_agent', args={'request': 'test1'} + ) + + mock_model = testing_utils.MockModel.create( + responses=[ + function_call, + 'response1', + 'response2', + ] + ) + + tool_agent = Agent( + name='tool_agent', + model=mock_model, + ) + + root_agent = Agent( + name='root_agent', + model=mock_model, + tools=[AgentTool(agent=tool_agent, event_callback=sync_callback)], + ) + + runner = testing_utils.InMemoryRunner(root_agent) + runner.run('test1') + + # Verify that events were captured + assert len(captured_events) > 0 + # All captured items should be Event instances + assert all(isinstance(e, Event) for e in captured_events) + # Should capture the tool agent's response + assert any( + e.content and any(p.text == 'response1' for p in e.content.parts) + for e in captured_events + ) + + +@mark.asyncio +async def test_event_callback_async_invocation(): + """Test that asynchronous event callbacks are invoked correctly.""" + captured_events = [] + + async def async_callback(event: Event) -> None: + captured_events.append(event) + + function_call = Part.from_function_call( + name='tool_agent', args={'request': 'test1'} + ) + + mock_model = testing_utils.MockModel.create( + responses=[ + function_call, + 'response1', + 'response2', + ] + ) + + tool_agent = Agent( + name='tool_agent', + model=mock_model, + ) + + root_agent = Agent( + name='root_agent', + model=mock_model, + tools=[AgentTool(agent=tool_agent, event_callback=async_callback)], + ) + + runner = testing_utils.InMemoryRunner(root_agent) + runner.run('test1') + + # Verify that events were captured + assert len(captured_events) > 0 + # All captured items should be Event instances + assert all(isinstance(e, Event) for e in captured_events) + # Should capture the tool agent's response + assert any( + e.content and any(p.text == 'response1' for p in e.content.parts) + for e in captured_events + ) + + +@mark.asyncio +async def test_event_callback_receives_all_events(): + """Test that callbacks receive all child agent events.""" + captured_events = [] + + def capture_callback(event: Event) -> None: + captured_events.append(event) + + function_call = Part.from_function_call( + name='tool_agent', args={'request': 'test1'} + ) + + mock_model = testing_utils.MockModel.create( + responses=[ + function_call, + 'response1', + 'response2', + ] + ) + + tool_agent = Agent( + name='tool_agent', + model=mock_model, + ) + + root_agent = Agent( + name='root_agent', + model=mock_model, + tools=[AgentTool(agent=tool_agent, event_callback=capture_callback)], + ) + + runner = testing_utils.InMemoryRunner(root_agent) + runner.run('test1') + + # Verify multiple events were captured (should include at least response) + assert len(captured_events) >= 1 + + # Check that events have expected structure + for event in captured_events: + assert isinstance(event, Event) + assert hasattr(event, 'author') + assert hasattr(event, 'content') + assert hasattr(event, 'actions') + + +@mark.asyncio +async def test_event_callback_backward_compatibility(): + """Test AgentTool works without event_callback (backward compatibility).""" + function_call = Part.from_function_call( + name='tool_agent', args={'request': 'test1'} + ) + + function_response = Part.from_function_response( + name='tool_agent', response={'result': 'response1'} + ) + + mock_model = testing_utils.MockModel.create( + responses=[ + function_call, + 'response1', + 'response2', + ] + ) + + tool_agent = Agent( + name='tool_agent', + model=mock_model, + ) + + # Create AgentTool without event_callback parameter + root_agent = Agent( + name='root_agent', + model=mock_model, + tools=[AgentTool(agent=tool_agent)], + ) + + runner = testing_utils.InMemoryRunner(root_agent) + + # Should work without errors + result = testing_utils.simplify_events(runner.run('test1')) + + assert result == [ + ('root_agent', function_call), + ('root_agent', function_response), + ('root_agent', 'response2'), + ] + + +@mark.asyncio +async def test_event_callback_can_access_event_metadata(): + """Test that callbacks can access event metadata like grounding_metadata.""" + captured_metadata = [] + + def metadata_callback(event: Event) -> None: + if event.grounding_metadata: + captured_metadata.append(event.grounding_metadata) + + function_call = Part.from_function_call( + name='tool_agent', args={'request': 'test1'} + ) + + mock_model = testing_utils.MockModel.create( + responses=[ + function_call, + 'response1', + 'response2', + ] + ) + + tool_agent = Agent( + name='tool_agent', + model=mock_model, + ) + + root_agent = Agent( + name='root_agent', + model=mock_model, + tools=[AgentTool(agent=tool_agent, event_callback=metadata_callback)], + ) + + runner = testing_utils.InMemoryRunner(root_agent) + runner.run('test1') + + # Test passes if no errors occur (grounding_metadata access works) + # Note: captured_metadata may be empty if mock doesn't provide metadata + + +@mark.asyncio +async def test_event_callback_with_multiple_tool_calls(): + """Test that callbacks work correctly with multiple tool invocations.""" + captured_events = [] + + def capture_callback(event: Event) -> None: + captured_events.append(event) + + function_call_1 = Part.from_function_call( + name='tool_agent', args={'request': 'call1'} + ) + function_call_2 = Part.from_function_call( + name='tool_agent', args={'request': 'call2'} + ) + + mock_model = testing_utils.MockModel.create( + responses=[ + function_call_1, + 'response1', + function_call_2, + 'response2', + 'final', + ] + ) + + tool_agent = Agent( + name='tool_agent', + model=mock_model, + ) + + root_agent = Agent( + name='root_agent', + model=mock_model, + tools=[AgentTool(agent=tool_agent, event_callback=capture_callback)], + ) + + runner = testing_utils.InMemoryRunner(root_agent) + runner.run('test1') + + # Should capture events from both tool invocations + assert len(captured_events) >= 2 + + # Verify we got responses from both calls + response_texts = [] + for event in captured_events: + if event.content: + for part in event.content.parts: + if part.text: + response_texts.append(part.text) + + assert 'response1' in response_texts + assert 'response2' in response_texts From e1c2afbb3a66529caa4d076026d28b6b268d8276 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko Date: Wed, 19 Nov 2025 17:28:03 +0100 Subject: [PATCH 2/5] Update src/google/adk/tools/agent_tool.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/google/adk/tools/agent_tool.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/google/adk/tools/agent_tool.py b/src/google/adk/tools/agent_tool.py index 8495fefca5..f8045d85fd 100644 --- a/src/google/adk/tools/agent_tool.py +++ b/src/google/adk/tools/agent_tool.py @@ -195,10 +195,15 @@ async def run_async( # Invoke user-provided event callback if present. if self.event_callback: - if inspect.iscoroutinefunction(self.event_callback): - await self.event_callback(event) - else: - self.event_callback(event) + try: + if inspect.iscoroutinefunction(self.event_callback): + await self.event_callback(event) + else: + self.event_callback(event) + except Exception as e: + logger.warning( + 'Error in AgentTool event_callback: %s', e, exc_info=True + ) # Clean up runner resources (especially MCP sessions) # to avoid "Attempted to exit cancel scope in a different task" errors From d105d3aa220eef70d91488a7f03cb41daff818b2 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko Date: Thu, 20 Nov 2025 10:52:15 +0100 Subject: [PATCH 3/5] chore: Add logging to agent_tool.py --- src/google/adk/tools/agent_tool.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/google/adk/tools/agent_tool.py b/src/google/adk/tools/agent_tool.py index f8045d85fd..81a0628185 100644 --- a/src/google/adk/tools/agent_tool.py +++ b/src/google/adk/tools/agent_tool.py @@ -15,6 +15,7 @@ from __future__ import annotations import inspect +import logging from typing import Any from typing import Awaitable from typing import Callable @@ -39,6 +40,8 @@ from ..agents.base_agent import BaseAgent from ..events.event import Event +logger = logging.getLogger('google_adk.' + __name__) + class AgentTool(BaseTool): """A tool that wraps an agent. From 836604355a23143ac01c95e8eed2f9a991c51429 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko Date: Thu, 4 Dec 2025 11:48:37 +0100 Subject: [PATCH 4/5] chore: autoformat --- src/google/adk/tools/agent_tool.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/google/adk/tools/agent_tool.py b/src/google/adk/tools/agent_tool.py index 81a0628185..f8665279b5 100644 --- a/src/google/adk/tools/agent_tool.py +++ b/src/google/adk/tools/agent_tool.py @@ -68,8 +68,8 @@ def __init__( *, include_plugins: bool = True, event_callback: Union[ - Callable[[Event], None], Callable[[Event], Awaitable[None]], None - ] = None + Callable[[Event], None], Callable[[Event], Awaitable[None]], None + ] = None, ): self.agent = agent self.skip_summarization: bool = skip_summarization From dfe347de84decc528c23ba1007a89838c380d98c Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko Date: Thu, 4 Dec 2025 11:54:23 +0100 Subject: [PATCH 5/5] chore: formatting --- contributing/samples/gepa/experiment.py | 1 + contributing/samples/gepa/run_experiment.py | 1 + 2 files changed, 2 insertions(+) diff --git a/contributing/samples/gepa/experiment.py b/contributing/samples/gepa/experiment.py index f68b349d9c..2f5d03a772 100644 --- a/contributing/samples/gepa/experiment.py +++ b/contributing/samples/gepa/experiment.py @@ -43,6 +43,7 @@ from tau_bench.types import EnvRunResult from tau_bench.types import RunConfig import tau_bench_agent as tau_bench_agent_lib + import utils diff --git a/contributing/samples/gepa/run_experiment.py b/contributing/samples/gepa/run_experiment.py index 1bc4ee58c8..cfd850b3a3 100644 --- a/contributing/samples/gepa/run_experiment.py +++ b/contributing/samples/gepa/run_experiment.py @@ -25,6 +25,7 @@ from absl import flags import experiment from google.genai import types + import utils _OUTPUT_DIR = flags.DEFINE_string(