From 2aab1cf98e1d0e8454764b549fac21475a633409 Mon Sep 17 00:00:00 2001 From: "Wei Sun (Jack)" Date: Mon, 21 Jul 2025 12:42:37 -0700 Subject: [PATCH] fix: allows current sub-agent to finish execution before exiting the loop agent due to a sub-agent's escalation. This commit also disables the summarization for `exit_loop` tool Fixes #423 Related to #1670 - This avoids the `GeneratorExit` error thrown, which would crash OTel metric collection and cause `Failed to detach context` error. - This also allows all function calls are processed when exit_loop is called together with other tools in the same LLmResponse. A sample agent for testing: ``` from google.adk import Agent from google.adk.agents.loop_agent import LoopAgent from google.adk.tools.exit_loop_tool import exit_loop worker_1 = Agent( name='worker_1', description='Worker 1', instruction="""\ Just say job #1 is done. If job #1 is said to be done. Call exit_loop tool.""", tools=[exit_loop], ) worker_2 = Agent( name='worker_2', description='Worker 2', instruction="""\ Just say job #2 is done. If job #2 is said to be done. Call exit_loop tool.""", tools=[exit_loop], ) work_agent = LoopAgent( name='work_agent', description='Do all work.', sub_agents=[worker_1, worker_2], max_iterations=5, ) root_agent = Agent( model='gemini-2.0-flash', name='hello_world_agent', description='hello world agent that can roll a check prime', instruction="""Hand off works to sub agents.""", sub_agents=[work_agent], ) ``` PiperOrigin-RevId: 785538101 --- src/google/adk/agents/loop_agent.py | 7 ++++++- src/google/adk/tools/exit_loop_tool.py | 1 + tests/unittests/agents/test_loop_agent.py | 17 +++++++++++++++-- .../flows/llm_flows/test_agent_transfer.py | 4 +--- 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/google/adk/agents/loop_agent.py b/src/google/adk/agents/loop_agent.py index 72157a8303..e582278648 100644 --- a/src/google/adk/agents/loop_agent.py +++ b/src/google/adk/agents/loop_agent.py @@ -53,10 +53,15 @@ async def _run_async_impl( times_looped = 0 while not self.max_iterations or times_looped < self.max_iterations: for sub_agent in self.sub_agents: + should_exit = False async for event in sub_agent.run_async(ctx): yield event if event.actions.escalate: - return + should_exit = True + + if should_exit: + return + times_looped += 1 return diff --git a/src/google/adk/tools/exit_loop_tool.py b/src/google/adk/tools/exit_loop_tool.py index 181dc7e90a..200b66e5dc 100644 --- a/src/google/adk/tools/exit_loop_tool.py +++ b/src/google/adk/tools/exit_loop_tool.py @@ -21,3 +21,4 @@ def exit_loop(tool_context: ToolContext): Call this function only when you are instructed to do so. """ tool_context.actions.escalate = True + tool_context.actions.skip_summarization = True diff --git a/tests/unittests/agents/test_loop_agent.py b/tests/unittests/agents/test_loop_agent.py index 33ff10fb71..30e1caa59c 100644 --- a/tests/unittests/agents/test_loop_agent.py +++ b/tests/unittests/agents/test_loop_agent.py @@ -68,6 +68,13 @@ async def _run_async_impl( ), actions=EventActions(escalate=True), ) + yield Event( + author=self.name, + invocation_id=ctx.invocation_id, + content=types.Content( + parts=[types.Part(text=f'I have done my job after escalation!!')] + ), + ) async def _create_parent_invocation_context( @@ -115,9 +122,12 @@ async def test_run_async_with_escalate_action(request: pytest.FixtureRequest): escalating_agent = _TestingAgentWithEscalateAction( name=f'{request.function.__name__}_test_escalating_agent' ) + ignored_agent = _TestingAgent( + name=f'{request.function.__name__}_test_ignored_agent' + ) loop_agent = LoopAgent( name=f'{request.function.__name__}_test_loop_agent', - sub_agents=[non_escalating_agent, escalating_agent], + sub_agents=[non_escalating_agent, escalating_agent, ignored_agent], ) parent_ctx = await _create_parent_invocation_context( request.function.__name__, loop_agent @@ -125,7 +135,7 @@ async def test_run_async_with_escalate_action(request: pytest.FixtureRequest): events = [e async for e in loop_agent.run_async(parent_ctx)] # Only two events are generated because the sub escalating_agent escalates. - assert len(events) == 2 + assert len(events) == 3 assert events[0].author == non_escalating_agent.name assert events[1].author == escalating_agent.name assert events[0].content.parts[0].text == ( @@ -134,3 +144,6 @@ async def test_run_async_with_escalate_action(request: pytest.FixtureRequest): assert events[1].content.parts[0].text == ( f'Hello, async {escalating_agent.name}!' ) + assert ( + events[2].content.parts[0].text == 'I have done my job after escalation!!' + ) diff --git a/tests/unittests/flows/llm_flows/test_agent_transfer.py b/tests/unittests/flows/llm_flows/test_agent_transfer.py index fe26c42a36..f660903d4a 100644 --- a/tests/unittests/flows/llm_flows/test_agent_transfer.py +++ b/tests/unittests/flows/llm_flows/test_agent_transfer.py @@ -303,11 +303,9 @@ def test_auto_to_loop(): name='exit_loop', response={'result': None} ), ), - # root_agent summarizes. - ('root_agent', 'response4'), ] # root_agent should still be the current agent because sub_agent_1 is loop. assert testing_utils.simplify_events(runner.run('test2')) == [ - ('root_agent', 'response5'), + ('root_agent', 'response4'), ]