Skip to content

Commit 19df9b9

Browse files
GWealecopybara-github
authored andcommitted
fix: prevent transfer_to_agent loop on resumable invocation replay
When an invocation is resumable, the resume short-circuit re-executed a persisted transfer_to_agent call and recursed into the target, which re-entered the short-circuit on the same call and looped with no LLM call. Only recurse for transfers emitted during a normal LLM step; on replay, surface the transfer via actions.transfer_to_agent and stop so the outer orchestrator dispatches the target once. Co-authored-by: George Weale <gweale@google.com> PiperOrigin-RevId: 952958253
1 parent 716be89 commit 19df9b9

2 files changed

Lines changed: 77 additions & 0 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1007,6 +1007,7 @@ async def _run_one_step_async(
10071007
if (
10081008
invocation_context.is_resumable
10091009
and events
1010+
and not events[-1].partial
10101011
and events[-1].get_function_calls()
10111012
):
10121013
model_response_event = events[-1]

tests/unittests/flows/llm_flows/test_base_llm_flow.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from google.adk.agents.llm_agent import Agent
2424
from google.adk.agents.loop_agent import LoopAgent
2525
from google.adk.agents.run_config import RunConfig
26+
from google.adk.apps.app import ResumabilityConfig
2627
from google.adk.events.event import Event
2728
from google.adk.features import FeatureName
2829
from google.adk.features._feature_registry import temporary_feature_override
@@ -36,6 +37,7 @@
3637
from google.adk.models.llm_request import LlmRequest
3738
from google.adk.models.llm_response import LlmResponse
3839
from google.adk.plugins.base_plugin import BasePlugin
40+
from google.adk.sessions.in_memory_session_service import InMemorySessionService
3941
from google.adk.tools.base_toolset import BaseToolset
4042
from google.adk.tools.google_search_tool import GoogleSearchTool
4143
from google.adk.utils.variant_utils import GoogleLLMVariant
@@ -2013,3 +2015,77 @@ async def test_finalize_dynamic_instructions_with_static_instruction():
20132015
assert llm_request.contents[0].parts[0].text == 'dynamic 1\n\ndynamic 2'
20142016
assert llm_request.contents[1].role == 'user'
20152017
assert llm_request.contents[1].parts[0].text == 'user question'
2018+
2019+
2020+
@pytest.mark.asyncio
2021+
async def test_resume_short_circuit_skips_partial_function_call():
2022+
"""A partial function_call at events[-1] must not drive the resume replay.
2023+
2024+
Partial events are SSE-display-only and never persisted to the session
2025+
(runners.py, invocation_context.py). If one leaks to events[-1] on a
2026+
resumable invocation, the resume short-circuit must ignore it and call the
2027+
LLM normally instead of re-executing it as a transfer.
2028+
"""
2029+
sub_agent = Agent(
2030+
name='sub_agent',
2031+
model=testing_utils.MockModel.create(responses=['unused']),
2032+
)
2033+
root_agent = Agent(
2034+
name='root_agent',
2035+
model=testing_utils.MockModel.create(responses=['llm called']),
2036+
sub_agents=[sub_agent],
2037+
)
2038+
2039+
session_service = InMemorySessionService()
2040+
session = await session_service.create_session(
2041+
app_name='test_app', user_id='test_user'
2042+
)
2043+
await session_service.append_event(
2044+
session,
2045+
Event(
2046+
invocation_id='i',
2047+
branch='root_agent',
2048+
author='user',
2049+
content=types.Content(
2050+
role='user', parts=[types.Part.from_text(text='go')]
2051+
),
2052+
),
2053+
)
2054+
# A consumer leaked a partial streaming transfer call into the session view;
2055+
# stock ADK filters these, a buggy consumer may not.
2056+
session.events.append(
2057+
Event(
2058+
invocation_id='i',
2059+
branch='root_agent',
2060+
author='root_agent',
2061+
partial=True,
2062+
content=types.Content(
2063+
role='model',
2064+
parts=[
2065+
types.Part.from_function_call(
2066+
name='transfer_to_agent', args={'agent_name': 'sub_agent'}
2067+
)
2068+
],
2069+
),
2070+
)
2071+
)
2072+
2073+
invocation_context = InvocationContext(
2074+
session_service=session_service,
2075+
invocation_id='i',
2076+
agent=root_agent,
2077+
session=session,
2078+
run_config=RunConfig(),
2079+
resumability_config=ResumabilityConfig(is_resumable=True),
2080+
branch='root_agent',
2081+
)
2082+
2083+
events = [
2084+
event
2085+
async for event in root_agent._llm_flow.run_async(invocation_context)
2086+
]
2087+
2088+
# The LLM was called once (short-circuit skipped) and the partial call was
2089+
# not re-executed as a transfer.
2090+
assert root_agent.model.response_index == 0
2091+
assert not any(e.actions and e.actions.transfer_to_agent for e in events)

0 commit comments

Comments
 (0)