Skip to content

Commit 9939e0b

Browse files
XinranTangcopybara-github
authored andcommitted
feat: Support resuming a parallel agent with multiple branches paused on tool confirmation requests
PiperOrigin-RevId: 817373403
1 parent cc24d61 commit 9939e0b

3 files changed

Lines changed: 272 additions & 0 deletions

File tree

src/google/adk/agents/llm_agent.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,11 @@ async def _run_async_impl(
409409
return
410410

411411
if ctx.is_resumable:
412+
events = ctx._get_events(current_invocation=True, current_branch=True)
413+
if events and ctx.should_pause_invocation(events[-1]):
414+
return
415+
# Only yield an end state if the last event is no longer a long running
416+
# tool call.
412417
ctx.set_agent_state(self.name, end_of_agent=True)
413418
yield self._create_agent_state_event(ctx)
414419

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,11 @@ async def _run_one_step_async(
388388
and events
389389
and events[-1].get_function_calls()
390390
):
391+
# Long running tool calls should have been handled before this point.
392+
# If there are still long running tool calls, it means the agent is paused
393+
# before, and its branch hasn't been resumed yet.
394+
if invocation_context.should_pause_invocation(events[-1]):
395+
return
391396
model_response_event = events[-1]
392397
async with Aclosing(
393398
self._postprocess_handle_function_calls_async(

tests/unittests/runners/test_run_tool_confirmation.py

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818
from unittest import mock
1919

2020
from google.adk.agents.base_agent import BaseAgent
21+
from google.adk.agents.base_agent import BaseAgentState
2122
from google.adk.agents.llm_agent import LlmAgent
23+
from google.adk.agents.parallel_agent import ParallelAgent
2224
from google.adk.agents.sequential_agent import SequentialAgent
2325
from google.adk.agents.sequential_agent import SequentialAgentState
2426
from google.adk.apps.app import App
@@ -549,10 +551,23 @@ async def test_pause_and_resume_on_request_confirmation(
549551
agent: SequentialAgent,
550552
):
551553
"""Tests HITL flow where all tool calls are confirmed."""
554+
555+
# Test setup:
556+
# - root_agent is a SequentialAgent with two sub-agents: sub_agent1 and
557+
# sub_agent2.
558+
# - sub_agent1 has a tool call that asks for HITL confirmation.
559+
# - sub_agent2 does not have any tool calls.
560+
# - The test will:
561+
# - Run the query and verify that the invocation is paused after the long
562+
# running tool call, at sub_agent1.
563+
# - Resume the invocation and execute the tool call from sub_agent1.
564+
# - Verify that root_agent continues to run sub_agent2.
565+
552566
events = runner.run("test user query")
553567
sub_agent1 = agent.sub_agents[0]
554568
sub_agent2 = agent.sub_agents[1]
555569

570+
# Step 1:
556571
# Verify that the invocation is paused after the long running tool call.
557572
# So that no intermediate function response and llm response is generated.
558573
# And the second sub agent is not started.
@@ -598,6 +613,7 @@ async def test_pause_and_resume_on_request_confirmation(
598613
)
599614
invocation_id = events[2].invocation_id
600615

616+
# Step 2:
601617
# Resume the invocation and confirm the tool call from sub_agent1, and
602618
# sub_agent2 will continue.
603619
user_confirmation = testing_utils.UserContent(
@@ -640,3 +656,249 @@ async def test_pause_and_resume_on_request_confirmation(
640656
testing_utils.simplify_resumable_app_events(copy.deepcopy(events))
641657
== expected_parts_final
642658
)
659+
660+
661+
class TestHITLConfirmationFlowWithParallelAgentAndResumableApp:
662+
"""Tests the HITL confirmation flow with a resumable sequential agent app."""
663+
664+
@pytest.fixture
665+
def tools(self) -> list[FunctionTool]:
666+
"""Provides the tools for the agent."""
667+
return [FunctionTool(func=_test_request_confirmation_function)]
668+
669+
@pytest.fixture
670+
def llm_responses(
671+
self, tools: list[FunctionTool]
672+
) -> list[GenerateContentResponse]:
673+
"""Provides mock LLM responses for the tests."""
674+
return [
675+
_create_llm_response_from_tools(tools),
676+
_create_llm_response_from_text("test llm response after tool call"),
677+
]
678+
679+
@pytest.fixture
680+
def agent(
681+
self,
682+
tools: list[FunctionTool],
683+
llm_responses: list[GenerateContentResponse],
684+
) -> ParallelAgent:
685+
"""Provides a single ParallelAgent for the test."""
686+
return ParallelAgent(
687+
name="root_agent",
688+
sub_agents=[
689+
LlmAgent(
690+
name="agent1",
691+
model=testing_utils.MockModel(responses=llm_responses),
692+
tools=tools,
693+
),
694+
LlmAgent(
695+
name="agent2",
696+
model=testing_utils.MockModel(responses=llm_responses),
697+
tools=tools,
698+
),
699+
],
700+
)
701+
702+
@pytest.fixture
703+
def runner(self, agent: ParallelAgent) -> testing_utils.InMemoryRunner:
704+
"""Provides an in-memory runner for the agent."""
705+
# Mark the app as resumable. So that the invocation will be paused after the
706+
# long running tool call.
707+
app = App(
708+
name="test_app",
709+
resumability_config=ResumabilityConfig(is_resumable=True),
710+
root_agent=agent,
711+
)
712+
return testing_utils.InMemoryRunner(app=app)
713+
714+
@pytest.mark.asyncio
715+
async def test_pause_and_resume_on_request_confirmation(
716+
self,
717+
runner: testing_utils.InMemoryRunner,
718+
agent: ParallelAgent,
719+
):
720+
"""Tests HITL flow where all tool calls are confirmed."""
721+
events = runner.run("test user query")
722+
723+
# Test setup:
724+
# - root_agent is a ParallelAgent with two sub-agents: sub_agent1 and
725+
# sub_agent2.
726+
# - Both sub_agents have a tool call that asks for HITL confirmation.
727+
# - The test will:
728+
# - Run the query and verify that each branch is paused after the long
729+
# running tool call.
730+
# - Resume the invocation and execute the tool call of each branch.
731+
732+
sub_agent1 = agent.sub_agents[0]
733+
sub_agent2 = agent.sub_agents[1]
734+
735+
# Verify that each branch is paused after the long running tool call.
736+
# So that no intermediate function response and llm response is generated.
737+
root_agent_events = [event for event in events if event.branch is None]
738+
sub_agent1_branch_events = [
739+
event
740+
for event in events
741+
if event.branch == f"{agent.name}.{sub_agent1.name}"
742+
]
743+
sub_agent2_branch_events = [
744+
event
745+
for event in events
746+
if event.branch == f"{agent.name}.{sub_agent2.name}"
747+
]
748+
assert testing_utils.simplify_resumable_app_events(
749+
copy.deepcopy(root_agent_events)
750+
) == [
751+
(
752+
agent.name,
753+
BaseAgentState().model_dump(mode="json"),
754+
),
755+
]
756+
assert testing_utils.simplify_resumable_app_events(
757+
copy.deepcopy(sub_agent1_branch_events)
758+
) == [
759+
(
760+
sub_agent1.name,
761+
Part(
762+
function_call=FunctionCall(
763+
name=sub_agent1.tools[0].name, args={}
764+
)
765+
),
766+
),
767+
(
768+
sub_agent1.name,
769+
Part(
770+
function_call=FunctionCall(
771+
name=REQUEST_CONFIRMATION_FUNCTION_CALL_NAME,
772+
args={
773+
"originalFunctionCall": {
774+
"name": sub_agent1.tools[0].name,
775+
"id": mock.ANY,
776+
"args": {},
777+
},
778+
"toolConfirmation": {
779+
"hint": "test hint for request_confirmation",
780+
"confirmed": False,
781+
},
782+
},
783+
)
784+
),
785+
),
786+
]
787+
assert testing_utils.simplify_resumable_app_events(
788+
copy.deepcopy(sub_agent2_branch_events)
789+
) == [
790+
(
791+
sub_agent2.name,
792+
Part(
793+
function_call=FunctionCall(
794+
name=sub_agent2.tools[0].name, args={}
795+
)
796+
),
797+
),
798+
(
799+
sub_agent2.name,
800+
Part(
801+
function_call=FunctionCall(
802+
name=REQUEST_CONFIRMATION_FUNCTION_CALL_NAME,
803+
args={
804+
"originalFunctionCall": {
805+
"name": sub_agent2.tools[0].name,
806+
"id": mock.ANY,
807+
"args": {},
808+
},
809+
"toolConfirmation": {
810+
"hint": "test hint for request_confirmation",
811+
"confirmed": False,
812+
},
813+
},
814+
)
815+
),
816+
),
817+
]
818+
819+
ask_for_confirmation_function_call_ids = [
820+
sub_agent1_branch_events[1].content.parts[0].function_call.id,
821+
sub_agent2_branch_events[1].content.parts[0].function_call.id,
822+
]
823+
assert (
824+
sub_agent1_branch_events[1].invocation_id
825+
== sub_agent2_branch_events[1].invocation_id
826+
)
827+
invocation_id = sub_agent1_branch_events[1].invocation_id
828+
829+
# Resume the invocation and confirm the tool call from sub_agent1.
830+
user_confirmations = [
831+
testing_utils.UserContent(
832+
Part(
833+
function_response=FunctionResponse(
834+
id=id,
835+
name=REQUEST_CONFIRMATION_FUNCTION_CALL_NAME,
836+
response={"confirmed": True},
837+
)
838+
)
839+
)
840+
for id in ask_for_confirmation_function_call_ids
841+
]
842+
843+
events = await runner.run_async(
844+
user_confirmations[0], invocation_id=invocation_id
845+
)
846+
for event in events:
847+
assert event.invocation_id == invocation_id
848+
849+
root_agent_events = [event for event in events if event.branch is None]
850+
sub_agent1_branch_events = [
851+
event
852+
for event in events
853+
if event.branch == f"{agent.name}.{sub_agent1.name}"
854+
]
855+
sub_agent2_branch_events = [
856+
event
857+
for event in events
858+
if event.branch == f"{agent.name}.{sub_agent2.name}"
859+
]
860+
861+
# Verify that sub_agent1 is resumed and final; sub_agent2 is still paused;
862+
# root_agent is not final.
863+
assert not root_agent_events
864+
assert not sub_agent2_branch_events
865+
assert testing_utils.simplify_resumable_app_events(
866+
copy.deepcopy(sub_agent1_branch_events)
867+
) == [
868+
(
869+
sub_agent1.name,
870+
Part(
871+
function_response=FunctionResponse(
872+
name=sub_agent1.tools[0].name,
873+
response={"result": "confirmed=True"},
874+
)
875+
),
876+
),
877+
(sub_agent1.name, "test llm response after tool call"),
878+
(sub_agent1.name, testing_utils.END_OF_AGENT),
879+
]
880+
881+
# Resume the invocation again and confirm the tool call from sub_agent2.
882+
events = await runner.run_async(
883+
user_confirmations[1], invocation_id=invocation_id
884+
)
885+
for event in events:
886+
assert event.invocation_id == invocation_id
887+
888+
# Verify that sub_agent2 is resumed and final; root_agent is final.
889+
assert testing_utils.simplify_resumable_app_events(
890+
copy.deepcopy(events)
891+
) == [
892+
(
893+
sub_agent2.name,
894+
Part(
895+
function_response=FunctionResponse(
896+
name=sub_agent1.tools[0].name,
897+
response={"result": "confirmed=True"},
898+
)
899+
),
900+
),
901+
(sub_agent2.name, "test llm response after tool call"),
902+
(sub_agent2.name, testing_utils.END_OF_AGENT),
903+
(agent.name, testing_utils.END_OF_AGENT),
904+
]

0 commit comments

Comments
 (0)