Skip to content

Commit ad5445a

Browse files
nileshd008haranrk
authored andcommitted
fix: Retain user input across transfer_to_agent when include_contents='none'
When an LlmAgent is configured with include_contents='none', the documented behavior is that the model receives no prior history and operates only on the current instruction and the latest user input. However, during an agent handoff via transfer_to_agent, the sub-agent's "current turn" incorrectly anchored on the parent's trailing transfer_to_agent events (the function call and its response) instead of the latest user input, so the user input was dropped from the sub-agent's context. Fix _get_current_turn_contents to skip trailing direct transfer_to_agent events when locating the start of the current turn, so the turn anchors on the latest user input (or a non-transfer model request). The transfer events are still included as context. Closes #3535 Co-authored-by: Haran Rajkumar <haranrk@google.com> PiperOrigin-RevId: 944016382
1 parent ecef5f8 commit ad5445a

2 files changed

Lines changed: 124 additions & 3 deletions

File tree

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

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -776,9 +776,13 @@ def _get_current_turn_contents(
776776
# Find the latest event that starts the current turn and process from there
777777
for i in range(len(events) - 1, -1, -1):
778778
event = events[i]
779-
if _should_include_event_in_context(
780-
current_branch, event, isolation_scope=isolation_scope
781-
) and (event.author == 'user' or _is_other_agent_reply(agent_name, event)):
779+
if (
780+
_should_include_event_in_context(
781+
current_branch, event, isolation_scope=isolation_scope
782+
)
783+
and (event.author == 'user' or _is_other_agent_reply(agent_name, event))
784+
and not _is_direct_transfer(event)
785+
):
782786
return _get_contents(
783787
current_branch,
784788
events[i:],
@@ -792,6 +796,30 @@ def _get_current_turn_contents(
792796
return []
793797

794798

799+
def _is_direct_transfer(event: Event) -> bool:
800+
"""Whether the event is a direct ``transfer_to_agent`` event.
801+
802+
When ``include_contents='none'`` and control is handed to a sub-agent via
803+
``transfer_to_agent``, the trailing transfer events (the function call and
804+
its response) must not be treated as the start of the current turn.
805+
Otherwise the sub-agent's turn would anchor on the parent's transfer event
806+
and drop the latest user input. Skipping these events lets the turn anchor
807+
on the real user input (or a non-transfer model request) instead, while the
808+
transfer events are still included as context.
809+
"""
810+
return bool(
811+
event.actions.transfer_to_agent
812+
or (
813+
event.content
814+
and event.content.parts
815+
and any(
816+
p.function_call and p.function_call.name == 'transfer_to_agent'
817+
for p in event.content.parts
818+
)
819+
)
820+
)
821+
822+
795823
def _is_other_agent_reply(current_agent_name: str, event: Event) -> bool:
796824
"""Whether the event is a reply from another agent."""
797825
# In live/bidi mode, all events from any agents, including the current

tests/unittests/flows/llm_flows/test_contents.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,99 @@ async def test_include_contents_none_multi_branch_current_turn():
254254
]
255255

256256

257+
@pytest.mark.asyncio
258+
async def test_events_with_transfer_to_agent_are_included():
259+
"""Test that the user input is retained across a transfer_to_agent handoff.
260+
261+
When include_contents='none' and control is transferred to a sub-agent, the
262+
current turn must anchor on the latest user input rather than the trailing
263+
transfer_to_agent events, while still including those transfer events as
264+
context.
265+
"""
266+
agent = Agent(
267+
model="gemini-2.5-flash", name="test_agent", include_contents="none"
268+
)
269+
llm_request = LlmRequest(model="gemini-2.5-flash")
270+
invocation_context = await testing_utils.create_invocation_context(
271+
agent=agent
272+
)
273+
274+
events = [
275+
Event(
276+
invocation_id="inv1",
277+
author="user",
278+
content=types.UserContent("First user message"),
279+
),
280+
Event(
281+
invocation_id="inv1",
282+
author="parent",
283+
content=types.Content(
284+
parts=[
285+
types.Part(
286+
function_call=types.FunctionCall(
287+
args={"agent_name": "test_agent"},
288+
id="call_inv1",
289+
name="transfer_to_agent",
290+
)
291+
)
292+
],
293+
role="model",
294+
),
295+
),
296+
Event(
297+
invocation_id="inv1",
298+
author="parent",
299+
content=types.Content(
300+
parts=[
301+
types.Part(
302+
function_response=types.FunctionResponse(
303+
id="call_inv1",
304+
name="transfer_to_agent",
305+
response={"result": None},
306+
),
307+
),
308+
],
309+
role="user",
310+
),
311+
actions=EventActions(transfer_to_agent="test_agent"),
312+
),
313+
]
314+
315+
invocation_context.session.events = events
316+
async for _ in contents.request_processor.run_async(
317+
invocation_context, llm_request
318+
):
319+
pass
320+
321+
assert llm_request.contents == [
322+
types.UserContent("First user message"),
323+
types.Content(
324+
parts=[
325+
types.Part(text="For context:"),
326+
types.Part(
327+
text=(
328+
"[parent] called tool `transfer_to_agent` with"
329+
" parameters: {'agent_name': 'test_agent'}"
330+
)
331+
),
332+
],
333+
role="user",
334+
),
335+
types.Content(
336+
parts=[
337+
types.Part(text="For context:"),
338+
types.Part(
339+
text=(
340+
"[parent] `transfer_to_agent` tool returned result:"
341+
" {'result': None}"
342+
)
343+
),
344+
],
345+
role="user",
346+
),
347+
]
348+
349+
257350
@pytest.mark.asyncio
258351
async def test_authentication_events_are_filtered():
259352
"""Test that authentication function calls and responses are filtered out."""

0 commit comments

Comments
 (0)