|
18 | 18 | from unittest import mock |
19 | 19 |
|
20 | 20 | from google.adk.agents.base_agent import BaseAgent |
| 21 | +from google.adk.agents.base_agent import BaseAgentState |
21 | 22 | from google.adk.agents.llm_agent import LlmAgent |
| 23 | +from google.adk.agents.parallel_agent import ParallelAgent |
22 | 24 | from google.adk.agents.sequential_agent import SequentialAgent |
23 | 25 | from google.adk.agents.sequential_agent import SequentialAgentState |
24 | 26 | from google.adk.apps.app import App |
@@ -549,10 +551,23 @@ async def test_pause_and_resume_on_request_confirmation( |
549 | 551 | agent: SequentialAgent, |
550 | 552 | ): |
551 | 553 | """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 | + |
552 | 566 | events = runner.run("test user query") |
553 | 567 | sub_agent1 = agent.sub_agents[0] |
554 | 568 | sub_agent2 = agent.sub_agents[1] |
555 | 569 |
|
| 570 | + # Step 1: |
556 | 571 | # Verify that the invocation is paused after the long running tool call. |
557 | 572 | # So that no intermediate function response and llm response is generated. |
558 | 573 | # And the second sub agent is not started. |
@@ -598,6 +613,7 @@ async def test_pause_and_resume_on_request_confirmation( |
598 | 613 | ) |
599 | 614 | invocation_id = events[2].invocation_id |
600 | 615 |
|
| 616 | + # Step 2: |
601 | 617 | # Resume the invocation and confirm the tool call from sub_agent1, and |
602 | 618 | # sub_agent2 will continue. |
603 | 619 | user_confirmation = testing_utils.UserContent( |
@@ -640,3 +656,249 @@ async def test_pause_and_resume_on_request_confirmation( |
640 | 656 | testing_utils.simplify_resumable_app_events(copy.deepcopy(events)) |
641 | 657 | == expected_parts_final |
642 | 658 | ) |
| 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