Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/google/adk/agents/_agent_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@ def find_agent_to_run(
- The root agent.
- An LlmAgent who replied last and is capable to transfer to any other agent
in the agent hierarchy.
- The target of the latest valid transfer_to_agent, when the target has not
replied since (e.g. it failed before yielding any event).

TODO: use wait_for_output to decide the agent to run

Expand Down Expand Up @@ -212,9 +214,26 @@ def _event_filter(event: Event) -> bool:
return False
if event.actions.agent_state is not None or event.actions.end_of_agent:
return False
# A node failure is recorded as an error event without content. It is not
# a reply, and its author may be inherited from the parent context, so it
# must not decide which agent owns the conversation.
if event.error_code and not (event.content and event.content.parts):
return False
return True

for event in filter(_event_filter, reversed(filtered_events)):
# The target of an unanswered transfer owns the turn. Resolving it among
# the author's transfer targets leaves transfers the scheduler rejected
# out of routing.
if (target_name := event.actions.transfer_to_agent) and (
source := root_agent.find_agent(event.author)
):
target = next(
(a for a in _get_transfer_targets(source) if a.name == target_name),
None,
)
if target is not None and is_transferable_across_agent_tree(target):
return target
if event.author == root_agent.name:
# Found root agent.
return root_agent
Expand Down
181 changes: 181 additions & 0 deletions tests/unittests/agents/test_agent_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -798,3 +798,184 @@ def test_find_agent_to_run_resolves_tool_sub_branch_pause_to_owning_agent(
_agent_router.find_agent_to_run(session, root, resumability_config)
== non_transferable
)


def _transfer_events(
author: str, target: str, *, call_id: str = "transfer_1"
) -> list[Event]:
"""Builds the call/response events persisted by transfer_to_agent."""
return [
Event(
invocation_id="inv1",
author=author,
content=types.Content(
role="model",
parts=[
types.Part(
function_call=types.FunctionCall(
id=call_id,
name="transfer_to_agent",
args={"agent_name": target},
)
)
],
),
),
Event(
invocation_id="inv1",
author=author,
content=types.Content(
role="user",
parts=[
types.Part(
function_response=types.FunctionResponse(
id=call_id,
name="transfer_to_agent",
response={"result": None},
)
)
],
),
actions=EventActions(transfer_to_agent=target),
),
]


def _text_event(author: str, text: str) -> Event:
return Event(
invocation_id="inv1",
author=author,
content=types.Content(role="model", parts=[types.Part(text=text)]),
)


def _node_failure_event(author: str) -> Event:
"""Builds the content-less error event NodeRunner records on failure."""
return Event(
invocation_id="inv1",
author=author,
error_code="UNAVAILABLE",
error_message="503 UNAVAILABLE",
)


def _session_with(events: list[Event]) -> Session:
return Session(id="s1", app_name="app", user_id="u1", events=events)


def test_find_agent_to_run_routes_to_target_of_unfinished_transfer():
"""Transfer target that never replied still owns the next turn."""
root, sub1, _, _ = _make_agent_tree()
session = _session_with(_transfer_events("root_agent", "sub_agent1"))

assert _agent_router.find_agent_to_run(session, root) == sub1


def test_find_agent_to_run_ignores_contentless_error_event_after_transfer():
"""Error event with an inherited author does not hide the transfer."""
root, sub1, _, _ = _make_agent_tree()
session = _session_with([
*_transfer_events("root_agent", "sub_agent1"),
_node_failure_event("root_agent"),
])

assert _agent_router.find_agent_to_run(session, root) == sub1


def test_find_agent_to_run_ignores_contentless_error_event_without_transfer():
"""Content-less error event does not take over from the last replier."""
root, sub1, _, _ = _make_agent_tree()
session = _session_with([
_text_event("sub_agent1", "Sub response"),
_node_failure_event("root_agent"),
])

assert _agent_router.find_agent_to_run(session, root) == sub1


def test_find_agent_to_run_routes_by_author_of_error_event_with_content():
"""Error event that carries content still counts as a reply."""
root, _, _, _ = _make_agent_tree()
error_reply = _text_event("root_agent", "Partial root response")
error_reply.error_code = "MAX_TOKENS"
session = _session_with([
_text_event("sub_agent1", "Sub response"),
error_reply,
])

assert _agent_router.find_agent_to_run(session, root) == root


def test_find_agent_to_run_routes_to_root_on_unfinished_transfer_to_parent():
"""Transfer back to the root that never replied routes to the root."""
root, _, _, _ = _make_agent_tree()
session = _session_with([
*_transfer_events("root_agent", "sub_agent1", call_id="transfer_1"),
_text_event("sub_agent1", "Sub response"),
*_transfer_events("sub_agent1", "root_agent", call_id="transfer_2"),
_node_failure_event("sub_agent1"),
])

assert _agent_router.find_agent_to_run(session, root) == root


def test_find_agent_to_run_routes_to_last_target_of_chained_transfer():
"""In root -> sub1 -> sub2, a failing sub2 still owns the next turn."""
root, _, sub2, _ = _make_agent_tree()
session = _session_with([
*_transfer_events("root_agent", "sub_agent1", call_id="transfer_1"),
*_transfer_events("sub_agent1", "sub_agent2", call_id="transfer_2"),
_node_failure_event("sub_agent1"),
])

assert _agent_router.find_agent_to_run(session, root) == sub2


def test_find_agent_to_run_prefers_later_reply_over_older_transfer():
"""A reply authored after a transfer decides routing, not the transfer."""
root, sub1, _, _ = _make_agent_tree()
session = _session_with([
*_transfer_events("root_agent", "sub_agent2"),
_text_event("sub_agent1", "Sub1 response"),
])

assert _agent_router.find_agent_to_run(session, root) == sub1


def test_find_agent_to_run_unfinished_transfer_to_non_transferable_target():
"""Target that cannot own a turn keeps the existing author-based routing."""
root, _, _, _ = _make_agent_tree()
session = _session_with(_transfer_events("root_agent", "non_transferable"))

assert _agent_router.find_agent_to_run(session, root) == root


def test_find_agent_to_run_ignores_transfer_rejected_by_peer_restriction():
"""Peer transfer forbidden for the author does not reroute the session."""
root, sub1, _, _ = _make_agent_tree()
sub1.disallow_transfer_to_peers = True
session = _session_with([
*_transfer_events("root_agent", "sub_agent1", call_id="transfer_1"),
_text_event("sub_agent1", "Sub response"),
*_transfer_events("sub_agent1", "sub_agent2", call_id="transfer_2"),
_node_failure_event("sub_agent1"),
])

assert _agent_router.find_agent_to_run(session, root) == sub1


def test_find_agent_to_run_ignores_transfer_rejected_by_parent_restriction():
"""Transfer to the parent forbidden for the author does not reroute."""
root, sub1, sub2, _ = _make_agent_tree()
child = _MockLlmAgent(
"child_agent", disallow_transfer_to_parent=True, parent_agent=sub1
)
sub1.sub_agents = [child]
session = _session_with([
_text_event("sub_agent2", "Sub2 response"),
*_transfer_events("child_agent", "sub_agent1"),
])

# child_agent cannot own a turn, so routing falls back to the earlier
# sub_agent2 reply instead of honouring the rejected transfer to sub_agent1.
assert _agent_router.find_agent_to_run(session, root) == sub2
48 changes: 48 additions & 0 deletions tests/unittests/workflow/test_agent_transfer.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from google.adk.sessions.in_memory_session_service import InMemorySessionService
from google.adk.tools.exit_loop_tool import exit_loop
from google.adk.tools.tool_context import ToolContext
from google.genai import errors
from google.genai.types import Part
import pytest

Expand Down Expand Up @@ -1254,3 +1255,50 @@ async def custom_node(*, ctx, node_input):
# Assert
assert ('inner_agent', transfer_call_part('target_agent')) in simplified
assert ('target_agent', 'hello from target') in simplified


@pytest.mark.parametrize('is_resumable', [True, False])
async def test_transfer_target_failing_before_first_event_owns_next_turn(
is_resumable: bool,
):
"""A target that fails before yielding still owns the next turn."""
# Arrange
root_model = testing_utils.MockModel.create(
responses=[transfer_call_part('sub_agent_1')]
)
sub_model = testing_utils.MockModel.create(responses=['response_sub'])
sub_model.error = errors.ServerError(
503, {'error': {'code': 503, 'status': 'UNAVAILABLE'}}
)

sub_agent_1 = Agent(name='sub_agent_1', model=sub_model)
root_agent = Agent(
name='root_agent',
model=root_model,
sub_agents=[sub_agent_1],
)
app = App(
name='test_app',
root_agent=root_agent,
resumability_config=ResumabilityConfig(is_resumable=is_resumable),
)
runner = testing_utils.InMemoryRunner(app=app)

# Act & Assert: Turn 1, the target's first model call fails
with pytest.raises(errors.ServerError):
await runner.run_async('test1')

# Turn 2: the model is healthy again and the target answers
sub_model.error = None
events = await runner.run_async('test2')

if not is_resumable:
assert testing_utils.simplify_events(events) == [
('sub_agent_1', 'response_sub'),
]
else:
assert testing_utils.simplify_resumable_app_events(events) == [
('sub_agent_1', 'response_sub'),
('sub_agent_1', END_OF_AGENT),
]
assert len(root_model.requests) == 1
Loading