ec2b666284
Continuous Integration / Pre-commit Linter (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.10) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.11) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.12) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.13) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.10) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.11) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.12) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.13) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.14) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Waiting to run
Copybara PR Handler / close-imported-pr (push) Waiting to run
1022 lines
33 KiB
Python
1022 lines
33 KiB
Python
# Copyright 2026 Google LLC
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""Tests for HITL flows with different agent structures."""
|
|
|
|
import copy
|
|
from unittest import mock
|
|
|
|
from google.adk.agents.base_agent import BaseAgent
|
|
from google.adk.agents.base_agent import BaseAgentState
|
|
from google.adk.agents.llm_agent import LlmAgent
|
|
from google.adk.agents.parallel_agent import ParallelAgent
|
|
from google.adk.agents.sequential_agent import SequentialAgent
|
|
from google.adk.agents.sequential_agent import SequentialAgentState
|
|
from google.adk.apps.app import App
|
|
from google.adk.apps.app import ResumabilityConfig
|
|
from google.adk.flows.llm_flows.functions import REQUEST_CONFIRMATION_FUNCTION_CALL_NAME
|
|
from google.adk.tools.function_tool import FunctionTool
|
|
from google.adk.tools.tool_context import ToolContext
|
|
from google.genai.types import FunctionCall
|
|
from google.genai.types import FunctionResponse
|
|
from google.genai.types import GenerateContentResponse
|
|
from google.genai.types import Part
|
|
import pytest
|
|
|
|
from .. import testing_utils
|
|
|
|
HINT_TEXT = (
|
|
"Please approve or reject the tool call _test_function() by"
|
|
" responding with a FunctionResponse with an"
|
|
" expected ToolConfirmation payload."
|
|
)
|
|
|
|
TOOL_CALL_ERROR_RESPONSE = {
|
|
"error": "This tool call requires confirmation, please approve or reject."
|
|
}
|
|
|
|
|
|
def _create_llm_response_from_tools(
|
|
tools: list[FunctionTool],
|
|
) -> GenerateContentResponse:
|
|
"""Creates a mock LLM response containing a function call."""
|
|
parts = [
|
|
Part(function_call=FunctionCall(name=tool.name, args={}))
|
|
for tool in tools
|
|
]
|
|
return testing_utils.LlmResponse(
|
|
content=testing_utils.ModelContent(parts=parts)
|
|
)
|
|
|
|
|
|
def _create_llm_response_from_text(text: str) -> GenerateContentResponse:
|
|
"""Creates a mock LLM response containing text."""
|
|
return testing_utils.LlmResponse(
|
|
content=testing_utils.ModelContent(parts=[Part(text=text)])
|
|
)
|
|
|
|
|
|
def _test_function(
|
|
tool_context: ToolContext,
|
|
) -> dict[str, str]:
|
|
return {"result": f"confirmed={tool_context.tool_confirmation.confirmed}"}
|
|
|
|
|
|
def _test_request_confirmation_function_with_custom_schema(
|
|
tool_context: ToolContext,
|
|
) -> dict[str, str]:
|
|
"""A test tool function that requests confirmation, but with a custom payload schema."""
|
|
if not tool_context.tool_confirmation:
|
|
tool_context.request_confirmation(
|
|
hint="test hint for request_confirmation with custom payload schema",
|
|
payload={
|
|
"test_custom_payload": {
|
|
"int_field": 0,
|
|
"str_field": "",
|
|
"bool_field": False,
|
|
}
|
|
},
|
|
)
|
|
return TOOL_CALL_ERROR_RESPONSE
|
|
return {
|
|
"result": f"confirmed={tool_context.tool_confirmation.confirmed}",
|
|
"custom_payload": tool_context.tool_confirmation.payload,
|
|
}
|
|
|
|
|
|
class BaseHITLTest:
|
|
"""Base class for HITL tests with common fixtures."""
|
|
|
|
@pytest.fixture
|
|
def runner(self, agent: BaseAgent) -> testing_utils.InMemoryRunner:
|
|
"""Provides an in-memory runner for the agent."""
|
|
return testing_utils.InMemoryRunner(root_agent=agent)
|
|
|
|
|
|
class TestHITLConfirmationFlowWithSingleAgent(BaseHITLTest):
|
|
"""Tests the HITL confirmation flow with a single LlmAgent."""
|
|
|
|
@pytest.fixture
|
|
def tools(self) -> list[FunctionTool]:
|
|
"""Provides the tools for the agent."""
|
|
return [FunctionTool(func=_test_function, require_confirmation=True)]
|
|
|
|
@pytest.fixture
|
|
def llm_responses(
|
|
self, tools: list[FunctionTool]
|
|
) -> list[GenerateContentResponse]:
|
|
"""Provides mock LLM responses for the tests."""
|
|
return [
|
|
_create_llm_response_from_tools(tools),
|
|
_create_llm_response_from_text("test llm response after tool call"),
|
|
]
|
|
|
|
@pytest.fixture
|
|
def mock_model(
|
|
self, llm_responses: list[GenerateContentResponse]
|
|
) -> testing_utils.MockModel:
|
|
"""Provides a mock model with predefined responses."""
|
|
return testing_utils.MockModel(responses=llm_responses)
|
|
|
|
@pytest.fixture
|
|
def agent(
|
|
self, mock_model: testing_utils.MockModel, tools: list[FunctionTool]
|
|
) -> LlmAgent:
|
|
"""Provides a single LlmAgent for the test."""
|
|
return LlmAgent(name="root_agent", model=mock_model, tools=tools)
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("tool_call_confirmed", [True, False])
|
|
async def test_confirmation_flow(
|
|
self,
|
|
runner: testing_utils.InMemoryRunner,
|
|
agent: LlmAgent,
|
|
tool_call_confirmed: bool,
|
|
):
|
|
"""Tests HITL flow where all tool calls are confirmed."""
|
|
user_query = testing_utils.UserContent("test user query")
|
|
events = await runner.run_async(user_query)
|
|
tools = agent.tools
|
|
|
|
expected_parts = [
|
|
(
|
|
agent.name,
|
|
Part(function_call=FunctionCall(name=tools[0].name, args={})),
|
|
),
|
|
(
|
|
agent.name,
|
|
Part(
|
|
function_call=FunctionCall(
|
|
name=REQUEST_CONFIRMATION_FUNCTION_CALL_NAME,
|
|
args={
|
|
"originalFunctionCall": {
|
|
"name": tools[0].name,
|
|
"id": mock.ANY,
|
|
"args": {},
|
|
},
|
|
"toolConfirmation": {
|
|
"hint": HINT_TEXT,
|
|
"confirmed": False,
|
|
},
|
|
},
|
|
)
|
|
),
|
|
),
|
|
(
|
|
agent.name,
|
|
Part(
|
|
function_response=FunctionResponse(
|
|
name=tools[0].name, response=TOOL_CALL_ERROR_RESPONSE
|
|
)
|
|
),
|
|
),
|
|
]
|
|
|
|
simplified = testing_utils.simplify_events(copy.deepcopy(events))
|
|
for i, (agent_name, part) in enumerate(expected_parts):
|
|
assert simplified[i][0] == agent_name
|
|
assert simplified[i][1] == part
|
|
|
|
ask_for_confirmation_function_call_id = (
|
|
events[1].content.parts[0].function_call.id
|
|
)
|
|
invocation_id = events[1].invocation_id
|
|
user_confirmation = testing_utils.UserContent(
|
|
Part(
|
|
function_response=FunctionResponse(
|
|
id=ask_for_confirmation_function_call_id,
|
|
name=REQUEST_CONFIRMATION_FUNCTION_CALL_NAME,
|
|
response={"confirmed": tool_call_confirmed},
|
|
)
|
|
)
|
|
)
|
|
events = await runner.run_async(user_confirmation)
|
|
|
|
expected_parts_final = [
|
|
(
|
|
agent.name,
|
|
Part(
|
|
function_response=FunctionResponse(
|
|
name=tools[0].name,
|
|
response={"result": f"confirmed={tool_call_confirmed}"}
|
|
if tool_call_confirmed
|
|
else {"error": "This tool call is rejected."},
|
|
)
|
|
),
|
|
),
|
|
(agent.name, "test llm response after tool call"),
|
|
]
|
|
for event in events:
|
|
assert event.invocation_id == invocation_id
|
|
assert (
|
|
testing_utils.simplify_events(copy.deepcopy(events))
|
|
== expected_parts_final
|
|
)
|
|
|
|
|
|
class TestHITLConfirmationFlowWithCustomPayloadSchema(BaseHITLTest):
|
|
"""Tests the HITL confirmation flow with a single agent, for custom confirmation payload schema."""
|
|
|
|
@pytest.fixture
|
|
def tools(self) -> list[FunctionTool]:
|
|
"""Provides the tools for the agent."""
|
|
return [
|
|
FunctionTool(
|
|
func=_test_request_confirmation_function_with_custom_schema
|
|
)
|
|
]
|
|
|
|
@pytest.fixture
|
|
def llm_responses(
|
|
self, tools: list[FunctionTool]
|
|
) -> list[GenerateContentResponse]:
|
|
"""Provides mock LLM responses for the tests."""
|
|
return [
|
|
_create_llm_response_from_tools(tools),
|
|
_create_llm_response_from_text("test llm response after tool call"),
|
|
_create_llm_response_from_text(
|
|
"test llm response after final tool call"
|
|
),
|
|
]
|
|
|
|
@pytest.fixture
|
|
def mock_model(
|
|
self, llm_responses: list[GenerateContentResponse]
|
|
) -> testing_utils.MockModel:
|
|
"""Provides a mock model with predefined responses."""
|
|
return testing_utils.MockModel(responses=llm_responses)
|
|
|
|
@pytest.fixture
|
|
def agent(
|
|
self, mock_model: testing_utils.MockModel, tools: list[FunctionTool]
|
|
) -> LlmAgent:
|
|
"""Provides a single LlmAgent for the test."""
|
|
return LlmAgent(name="root_agent", model=mock_model, tools=tools)
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("tool_call_confirmed", [True, False])
|
|
async def test_confirmation_flow(
|
|
self,
|
|
runner: testing_utils.InMemoryRunner,
|
|
agent: LlmAgent,
|
|
tool_call_confirmed: bool,
|
|
):
|
|
"""Tests HITL flow with custom payload schema."""
|
|
tools = agent.tools
|
|
user_query = testing_utils.UserContent("test user query")
|
|
events = await runner.run_async(user_query)
|
|
|
|
expected_parts = [
|
|
(
|
|
agent.name,
|
|
Part(function_call=FunctionCall(name=tools[0].name, args={})),
|
|
),
|
|
(
|
|
agent.name,
|
|
Part(
|
|
function_call=FunctionCall(
|
|
name=REQUEST_CONFIRMATION_FUNCTION_CALL_NAME,
|
|
args={
|
|
"originalFunctionCall": {
|
|
"name": tools[0].name,
|
|
"id": mock.ANY,
|
|
"args": {},
|
|
},
|
|
"toolConfirmation": {
|
|
"hint": (
|
|
"test hint for request_confirmation with"
|
|
" custom payload schema"
|
|
),
|
|
"confirmed": False,
|
|
"payload": {
|
|
"test_custom_payload": {
|
|
"int_field": 0,
|
|
"str_field": "",
|
|
"bool_field": False,
|
|
}
|
|
},
|
|
},
|
|
},
|
|
)
|
|
),
|
|
),
|
|
(
|
|
agent.name,
|
|
Part(
|
|
function_response=FunctionResponse(
|
|
name=tools[0].name, response=TOOL_CALL_ERROR_RESPONSE
|
|
)
|
|
),
|
|
),
|
|
(agent.name, "test llm response after tool call"),
|
|
]
|
|
|
|
simplified = testing_utils.simplify_events(copy.deepcopy(events))
|
|
for i, (agent_name, part) in enumerate(expected_parts):
|
|
assert simplified[i][0] == agent_name
|
|
assert simplified[i][1] == part
|
|
|
|
ask_for_confirmation_function_call_id = (
|
|
events[1].content.parts[0].function_call.id
|
|
)
|
|
invocation_id = events[1].invocation_id
|
|
custom_payload = {
|
|
"test_custom_payload": {
|
|
"int_field": 123,
|
|
"str_field": "test_str",
|
|
"bool_field": True,
|
|
}
|
|
}
|
|
user_confirmation = testing_utils.UserContent(
|
|
Part(
|
|
function_response=FunctionResponse(
|
|
id=ask_for_confirmation_function_call_id,
|
|
name=REQUEST_CONFIRMATION_FUNCTION_CALL_NAME,
|
|
response={
|
|
"confirmed": tool_call_confirmed,
|
|
"payload": custom_payload,
|
|
},
|
|
)
|
|
)
|
|
)
|
|
events = await runner.run_async(user_confirmation)
|
|
|
|
expected_response = {
|
|
"result": f"confirmed={tool_call_confirmed}",
|
|
"custom_payload": custom_payload,
|
|
}
|
|
expected_parts_final = [
|
|
(
|
|
agent.name,
|
|
Part(
|
|
function_response=FunctionResponse(
|
|
name=tools[0].name,
|
|
response=expected_response,
|
|
)
|
|
),
|
|
),
|
|
(agent.name, "test llm response after final tool call"),
|
|
]
|
|
for event in events:
|
|
assert event.invocation_id == invocation_id
|
|
assert (
|
|
testing_utils.simplify_events(copy.deepcopy(events))
|
|
== expected_parts_final
|
|
)
|
|
|
|
|
|
class TestHITLConfirmationFlowWithResumableApp:
|
|
"""Tests the HITL confirmation flow with a resumable app."""
|
|
|
|
@pytest.fixture
|
|
def tools(self) -> list[FunctionTool]:
|
|
"""Provides the tools for the agent."""
|
|
return [FunctionTool(func=_test_function, require_confirmation=True)]
|
|
|
|
@pytest.fixture
|
|
def llm_responses(
|
|
self, tools: list[FunctionTool]
|
|
) -> list[GenerateContentResponse]:
|
|
"""Provides mock LLM responses for the tests."""
|
|
return [
|
|
_create_llm_response_from_tools(tools),
|
|
_create_llm_response_from_text("test llm response after tool call"),
|
|
]
|
|
|
|
@pytest.fixture
|
|
def mock_model(
|
|
self, llm_responses: list[GenerateContentResponse]
|
|
) -> testing_utils.MockModel:
|
|
"""Provides a mock model with predefined responses."""
|
|
return testing_utils.MockModel(responses=llm_responses)
|
|
|
|
@pytest.fixture
|
|
def agent(
|
|
self, mock_model: testing_utils.MockModel, tools: list[FunctionTool]
|
|
) -> LlmAgent:
|
|
"""Provides a single LlmAgent for the test."""
|
|
return LlmAgent(name="root_agent", model=mock_model, tools=tools)
|
|
|
|
@pytest.fixture
|
|
def runner(self, agent: LlmAgent) -> testing_utils.InMemoryRunner:
|
|
"""Provides an in-memory runner for the agent."""
|
|
# Mark the app as resumable. So that the invocation will be paused when
|
|
# tool confirmation is requested.
|
|
app = App(
|
|
name="test_app",
|
|
resumability_config=ResumabilityConfig(is_resumable=True),
|
|
root_agent=agent,
|
|
)
|
|
return testing_utils.InMemoryRunner(app=app)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pause_and_resume_on_request_confirmation(
|
|
self,
|
|
runner: testing_utils.InMemoryRunner,
|
|
agent: LlmAgent,
|
|
):
|
|
"""Tests HITL flow where all tool calls are confirmed."""
|
|
events = runner.run("test user query")
|
|
|
|
# Verify that the invocation is paused when tool confirmation is requested.
|
|
# The tool call returns error response, and summarization was skipped.
|
|
assert testing_utils.simplify_resumable_app_events(
|
|
copy.deepcopy(events)
|
|
) == [
|
|
(
|
|
agent.name,
|
|
Part(function_call=FunctionCall(name=agent.tools[0].name, args={})),
|
|
),
|
|
(
|
|
agent.name,
|
|
Part(
|
|
function_call=FunctionCall(
|
|
name=REQUEST_CONFIRMATION_FUNCTION_CALL_NAME,
|
|
args={
|
|
"originalFunctionCall": {
|
|
"name": agent.tools[0].name,
|
|
"id": mock.ANY,
|
|
"args": {},
|
|
},
|
|
"toolConfirmation": {
|
|
"hint": HINT_TEXT,
|
|
"confirmed": False,
|
|
},
|
|
},
|
|
)
|
|
),
|
|
),
|
|
(
|
|
agent.name,
|
|
Part(
|
|
function_response=FunctionResponse(
|
|
name=agent.tools[0].name, response=TOOL_CALL_ERROR_RESPONSE
|
|
)
|
|
),
|
|
),
|
|
]
|
|
ask_for_confirmation_function_call_id = (
|
|
events[1].content.parts[0].function_call.id
|
|
)
|
|
invocation_id = events[1].invocation_id
|
|
user_confirmation = testing_utils.UserContent(
|
|
Part(
|
|
function_response=FunctionResponse(
|
|
id=ask_for_confirmation_function_call_id,
|
|
name=REQUEST_CONFIRMATION_FUNCTION_CALL_NAME,
|
|
response={"confirmed": True},
|
|
)
|
|
)
|
|
)
|
|
events = await runner.run_async(
|
|
user_confirmation, invocation_id=invocation_id
|
|
)
|
|
expected_parts_final = [
|
|
(
|
|
agent.name,
|
|
Part(
|
|
function_response=FunctionResponse(
|
|
name=agent.tools[0].name,
|
|
response={"result": "confirmed=True"},
|
|
)
|
|
),
|
|
),
|
|
(agent.name, "test llm response after tool call"),
|
|
(agent.name, testing_utils.END_OF_AGENT),
|
|
]
|
|
for event in events:
|
|
assert event.invocation_id == invocation_id
|
|
assert (
|
|
testing_utils.simplify_resumable_app_events(copy.deepcopy(events))
|
|
== expected_parts_final
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pause_and_resume_on_request_confirmation_without_invocation_id(
|
|
self,
|
|
runner: testing_utils.InMemoryRunner,
|
|
agent: LlmAgent,
|
|
):
|
|
"""Tests HITL flow where all tool calls are confirmed."""
|
|
events = runner.run("test user query")
|
|
|
|
# Verify that the invocation is paused when tool confirmation is requested.
|
|
# The tool call returns error response, and summarization was skipped.
|
|
assert testing_utils.simplify_resumable_app_events(
|
|
copy.deepcopy(events)
|
|
) == [
|
|
(
|
|
agent.name,
|
|
Part(function_call=FunctionCall(name=agent.tools[0].name, args={})),
|
|
),
|
|
(
|
|
agent.name,
|
|
Part(
|
|
function_call=FunctionCall(
|
|
name=REQUEST_CONFIRMATION_FUNCTION_CALL_NAME,
|
|
args={
|
|
"originalFunctionCall": {
|
|
"name": agent.tools[0].name,
|
|
"id": mock.ANY,
|
|
"args": {},
|
|
},
|
|
"toolConfirmation": {
|
|
"hint": HINT_TEXT,
|
|
"confirmed": False,
|
|
},
|
|
},
|
|
)
|
|
),
|
|
),
|
|
(
|
|
agent.name,
|
|
Part(
|
|
function_response=FunctionResponse(
|
|
name=agent.tools[0].name, response=TOOL_CALL_ERROR_RESPONSE
|
|
)
|
|
),
|
|
),
|
|
]
|
|
ask_for_confirmation_function_call_id = (
|
|
events[1].content.parts[0].function_call.id
|
|
)
|
|
invocation_id = events[1].invocation_id
|
|
user_confirmation = testing_utils.UserContent(
|
|
Part(
|
|
function_response=FunctionResponse(
|
|
id=ask_for_confirmation_function_call_id,
|
|
name=REQUEST_CONFIRMATION_FUNCTION_CALL_NAME,
|
|
response={"confirmed": True},
|
|
)
|
|
)
|
|
)
|
|
events = await runner.run_async(user_confirmation)
|
|
expected_parts_final = [
|
|
(
|
|
agent.name,
|
|
Part(
|
|
function_response=FunctionResponse(
|
|
name=agent.tools[0].name,
|
|
response={"result": "confirmed=True"},
|
|
)
|
|
),
|
|
),
|
|
(agent.name, "test llm response after tool call"),
|
|
(agent.name, testing_utils.END_OF_AGENT),
|
|
]
|
|
for event in events:
|
|
assert event.invocation_id == invocation_id
|
|
assert (
|
|
testing_utils.simplify_resumable_app_events(copy.deepcopy(events))
|
|
== expected_parts_final
|
|
)
|
|
|
|
|
|
class TestHITLConfirmationFlowWithSequentialAgentAndResumableApp:
|
|
"""Tests the HITL confirmation flow with a resumable sequential agent app."""
|
|
|
|
@pytest.fixture
|
|
def tools(self) -> list[FunctionTool]:
|
|
"""Provides the tools for the agent."""
|
|
return [FunctionTool(func=_test_function, require_confirmation=True)]
|
|
|
|
@pytest.fixture
|
|
def llm_responses(
|
|
self, tools: list[FunctionTool]
|
|
) -> list[GenerateContentResponse]:
|
|
"""Provides mock LLM responses for the tests."""
|
|
return [
|
|
_create_llm_response_from_tools(tools),
|
|
_create_llm_response_from_text("test llm response after tool call"),
|
|
_create_llm_response_from_text("test llm response from second agent"),
|
|
]
|
|
|
|
@pytest.fixture
|
|
def mock_model(
|
|
self, llm_responses: list[GenerateContentResponse]
|
|
) -> testing_utils.MockModel:
|
|
"""Provides a mock model with predefined responses."""
|
|
return testing_utils.MockModel(responses=llm_responses)
|
|
|
|
@pytest.fixture
|
|
def agent(
|
|
self, mock_model: testing_utils.MockModel, tools: list[FunctionTool]
|
|
) -> SequentialAgent:
|
|
"""Provides a single LlmAgent for the test."""
|
|
return SequentialAgent(
|
|
name="root_agent",
|
|
sub_agents=[
|
|
LlmAgent(name="agent1", model=mock_model, tools=tools),
|
|
LlmAgent(name="agent2", model=mock_model, tools=[]),
|
|
],
|
|
)
|
|
|
|
@pytest.fixture
|
|
def runner(self, agent: SequentialAgent) -> testing_utils.InMemoryRunner:
|
|
"""Provides an in-memory runner for the agent."""
|
|
# Mark the app as resumable. So that the invocation will be paused when
|
|
# tool confirmation is requested.
|
|
app = App(
|
|
name="test_app",
|
|
resumability_config=ResumabilityConfig(is_resumable=True),
|
|
root_agent=agent,
|
|
)
|
|
return testing_utils.InMemoryRunner(app=app)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pause_and_resume_on_request_confirmation(
|
|
self,
|
|
runner: testing_utils.InMemoryRunner,
|
|
agent: SequentialAgent,
|
|
):
|
|
"""Tests HITL flow where all tool calls are confirmed."""
|
|
|
|
# Test setup:
|
|
# - root_agent is a SequentialAgent with two sub-agents: sub_agent1 and
|
|
# sub_agent2.
|
|
# - sub_agent1 has a tool call that asks for HITL confirmation.
|
|
# - sub_agent2 does not have any tool calls.
|
|
# - The test will:
|
|
# - Run the query and verify that the invocation is paused when tool
|
|
# confirmation is requested, at sub_agent1.
|
|
# - Resume the invocation and execute the tool call from sub_agent1.
|
|
# - Verify that root_agent continues to run sub_agent2.
|
|
|
|
events = runner.run("test user query")
|
|
sub_agent1 = agent.sub_agents[0]
|
|
sub_agent2 = agent.sub_agents[1]
|
|
|
|
# Step 1:
|
|
# Verify that the invocation is paused when tool confirmation is requested.
|
|
# So that no intermediate llm response is generated.
|
|
# And the second sub agent is not started.
|
|
assert testing_utils.simplify_resumable_app_events(
|
|
copy.deepcopy(events)
|
|
) == [
|
|
(
|
|
agent.name,
|
|
SequentialAgentState(current_sub_agent=sub_agent1.name).model_dump(
|
|
mode="json"
|
|
),
|
|
),
|
|
(
|
|
sub_agent1.name,
|
|
Part(
|
|
function_call=FunctionCall(
|
|
name=sub_agent1.tools[0].name, args={}
|
|
)
|
|
),
|
|
),
|
|
(
|
|
sub_agent1.name,
|
|
Part(
|
|
function_call=FunctionCall(
|
|
name=REQUEST_CONFIRMATION_FUNCTION_CALL_NAME,
|
|
args={
|
|
"originalFunctionCall": {
|
|
"name": sub_agent1.tools[0].name,
|
|
"id": mock.ANY,
|
|
"args": {},
|
|
},
|
|
"toolConfirmation": {
|
|
"hint": HINT_TEXT,
|
|
"confirmed": False,
|
|
},
|
|
},
|
|
)
|
|
),
|
|
),
|
|
(
|
|
sub_agent1.name,
|
|
Part(
|
|
function_response=FunctionResponse(
|
|
name=sub_agent1.tools[0].name,
|
|
response=TOOL_CALL_ERROR_RESPONSE,
|
|
)
|
|
),
|
|
),
|
|
]
|
|
ask_for_confirmation_function_call_id = (
|
|
events[2].content.parts[0].function_call.id
|
|
)
|
|
invocation_id = events[2].invocation_id
|
|
|
|
# Step 2:
|
|
# Resume the invocation and confirm the tool call from sub_agent1, and
|
|
# sub_agent2 will continue.
|
|
user_confirmation = testing_utils.UserContent(
|
|
Part(
|
|
function_response=FunctionResponse(
|
|
id=ask_for_confirmation_function_call_id,
|
|
name=REQUEST_CONFIRMATION_FUNCTION_CALL_NAME,
|
|
response={"confirmed": True},
|
|
)
|
|
)
|
|
)
|
|
events = await runner.run_async(
|
|
user_confirmation, invocation_id=invocation_id
|
|
)
|
|
expected_parts_final = [
|
|
(
|
|
sub_agent1.name,
|
|
Part(
|
|
function_response=FunctionResponse(
|
|
name=sub_agent1.tools[0].name,
|
|
response={"result": "confirmed=True"},
|
|
)
|
|
),
|
|
),
|
|
(sub_agent1.name, "test llm response after tool call"),
|
|
(sub_agent1.name, testing_utils.END_OF_AGENT),
|
|
(
|
|
agent.name,
|
|
SequentialAgentState(current_sub_agent=sub_agent2.name).model_dump(
|
|
mode="json"
|
|
),
|
|
),
|
|
(sub_agent2.name, "test llm response from second agent"),
|
|
(sub_agent2.name, testing_utils.END_OF_AGENT),
|
|
(agent.name, testing_utils.END_OF_AGENT),
|
|
]
|
|
for event in events:
|
|
assert event.invocation_id == invocation_id
|
|
assert (
|
|
testing_utils.simplify_resumable_app_events(copy.deepcopy(events))
|
|
== expected_parts_final
|
|
)
|
|
|
|
|
|
class TestHITLConfirmationFlowWithParallelAgentAndResumableApp:
|
|
"""Tests the HITL confirmation flow with a resumable sequential agent app."""
|
|
|
|
@pytest.fixture
|
|
def tools(self) -> list[FunctionTool]:
|
|
"""Provides the tools for the agent."""
|
|
return [FunctionTool(func=_test_function, require_confirmation=True)]
|
|
|
|
@pytest.fixture
|
|
def llm_responses(
|
|
self, tools: list[FunctionTool]
|
|
) -> list[GenerateContentResponse]:
|
|
"""Provides mock LLM responses for the tests."""
|
|
return [
|
|
_create_llm_response_from_tools(tools),
|
|
_create_llm_response_from_text("test llm response after tool call"),
|
|
]
|
|
|
|
@pytest.fixture
|
|
def agent(
|
|
self,
|
|
tools: list[FunctionTool],
|
|
llm_responses: list[GenerateContentResponse],
|
|
) -> ParallelAgent:
|
|
"""Provides a single ParallelAgent for the test."""
|
|
return ParallelAgent(
|
|
name="root_agent",
|
|
sub_agents=[
|
|
LlmAgent(
|
|
name="agent1",
|
|
model=testing_utils.MockModel(responses=llm_responses),
|
|
tools=tools,
|
|
),
|
|
LlmAgent(
|
|
name="agent2",
|
|
model=testing_utils.MockModel(responses=llm_responses),
|
|
tools=tools,
|
|
),
|
|
],
|
|
)
|
|
|
|
@pytest.fixture
|
|
def runner(self, agent: ParallelAgent) -> testing_utils.InMemoryRunner:
|
|
"""Provides an in-memory runner for the agent."""
|
|
# Mark the app as resumable. So that the invocation will be paused when
|
|
# tool confirmation is requested.
|
|
app = App(
|
|
name="test_app",
|
|
resumability_config=ResumabilityConfig(is_resumable=True),
|
|
root_agent=agent,
|
|
)
|
|
return testing_utils.InMemoryRunner(app=app)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pause_and_resume_on_request_confirmation(
|
|
self,
|
|
runner: testing_utils.InMemoryRunner,
|
|
agent: ParallelAgent,
|
|
):
|
|
"""Tests HITL flow where all tool calls are confirmed."""
|
|
events = runner.run("test user query")
|
|
|
|
# Test setup:
|
|
# - root_agent is a ParallelAgent with two sub-agents: sub_agent1 and
|
|
# sub_agent2.
|
|
# - Both sub_agents have a tool call that asks for HITL confirmation.
|
|
# - The test will:
|
|
# - Run the query and verify that each branch is paused when tool
|
|
# confirmation is requested.
|
|
# - Resume the invocation and execute the tool call of each branch.
|
|
|
|
sub_agent1 = agent.sub_agents[0]
|
|
sub_agent2 = agent.sub_agents[1]
|
|
|
|
# Verify that each branch is paused after the long running tool call.
|
|
# So that no intermediate llm response is generated.
|
|
root_agent_events = [event for event in events if event.branch is None]
|
|
sub_agent1_branch_events = [
|
|
event
|
|
for event in events
|
|
if event.branch == f"{agent.name}.{sub_agent1.name}"
|
|
]
|
|
sub_agent2_branch_events = [
|
|
event
|
|
for event in events
|
|
if event.branch == f"{agent.name}.{sub_agent2.name}"
|
|
]
|
|
assert testing_utils.simplify_resumable_app_events(
|
|
copy.deepcopy(root_agent_events)
|
|
) == [
|
|
(
|
|
agent.name,
|
|
BaseAgentState().model_dump(mode="json"),
|
|
),
|
|
]
|
|
assert testing_utils.simplify_resumable_app_events(
|
|
copy.deepcopy(sub_agent1_branch_events)
|
|
) == [
|
|
(
|
|
sub_agent1.name,
|
|
Part(
|
|
function_call=FunctionCall(
|
|
name=sub_agent1.tools[0].name, args={}
|
|
)
|
|
),
|
|
),
|
|
(
|
|
sub_agent1.name,
|
|
Part(
|
|
function_call=FunctionCall(
|
|
name=REQUEST_CONFIRMATION_FUNCTION_CALL_NAME,
|
|
args={
|
|
"originalFunctionCall": {
|
|
"name": sub_agent1.tools[0].name,
|
|
"id": mock.ANY,
|
|
"args": {},
|
|
},
|
|
"toolConfirmation": {
|
|
"hint": HINT_TEXT,
|
|
"confirmed": False,
|
|
},
|
|
},
|
|
)
|
|
),
|
|
),
|
|
(
|
|
sub_agent1.name,
|
|
Part(
|
|
function_response=FunctionResponse(
|
|
name=sub_agent1.tools[0].name,
|
|
response=TOOL_CALL_ERROR_RESPONSE,
|
|
)
|
|
),
|
|
),
|
|
]
|
|
assert testing_utils.simplify_resumable_app_events(
|
|
copy.deepcopy(sub_agent2_branch_events)
|
|
) == [
|
|
(
|
|
sub_agent2.name,
|
|
Part(
|
|
function_call=FunctionCall(
|
|
name=sub_agent2.tools[0].name, args={}
|
|
)
|
|
),
|
|
),
|
|
(
|
|
sub_agent2.name,
|
|
Part(
|
|
function_call=FunctionCall(
|
|
name=REQUEST_CONFIRMATION_FUNCTION_CALL_NAME,
|
|
args={
|
|
"originalFunctionCall": {
|
|
"name": sub_agent2.tools[0].name,
|
|
"id": mock.ANY,
|
|
"args": {},
|
|
},
|
|
"toolConfirmation": {
|
|
"hint": HINT_TEXT,
|
|
"confirmed": False,
|
|
},
|
|
},
|
|
)
|
|
),
|
|
),
|
|
(
|
|
sub_agent2.name,
|
|
Part(
|
|
function_response=FunctionResponse(
|
|
name=sub_agent2.tools[0].name,
|
|
response=TOOL_CALL_ERROR_RESPONSE,
|
|
)
|
|
),
|
|
),
|
|
]
|
|
|
|
ask_for_confirmation_function_call_ids = [
|
|
sub_agent1_branch_events[1].content.parts[0].function_call.id,
|
|
sub_agent2_branch_events[1].content.parts[0].function_call.id,
|
|
]
|
|
assert (
|
|
sub_agent1_branch_events[1].invocation_id
|
|
== sub_agent2_branch_events[1].invocation_id
|
|
)
|
|
invocation_id = sub_agent1_branch_events[1].invocation_id
|
|
|
|
# Resume the invocation and confirm the tool call from sub_agent1.
|
|
user_confirmations = [
|
|
testing_utils.UserContent(
|
|
Part(
|
|
function_response=FunctionResponse(
|
|
id=id,
|
|
name=REQUEST_CONFIRMATION_FUNCTION_CALL_NAME,
|
|
response={"confirmed": True},
|
|
)
|
|
)
|
|
)
|
|
for id in ask_for_confirmation_function_call_ids
|
|
]
|
|
|
|
events = await runner.run_async(
|
|
user_confirmations[0], invocation_id=invocation_id
|
|
)
|
|
for event in events:
|
|
assert event.invocation_id == invocation_id
|
|
|
|
root_agent_events = [event for event in events if event.branch is None]
|
|
sub_agent1_branch_events = [
|
|
event
|
|
for event in events
|
|
if event.branch == f"{agent.name}.{sub_agent1.name}"
|
|
]
|
|
sub_agent2_branch_events = [
|
|
event
|
|
for event in events
|
|
if event.branch == f"{agent.name}.{sub_agent2.name}"
|
|
]
|
|
|
|
# Verify that sub_agent1 is resumed and final; sub_agent2 is still paused;
|
|
# root_agent is not final.
|
|
assert not root_agent_events
|
|
assert not sub_agent2_branch_events
|
|
assert testing_utils.simplify_resumable_app_events(
|
|
copy.deepcopy(sub_agent1_branch_events)
|
|
) == [
|
|
(
|
|
sub_agent1.name,
|
|
Part(
|
|
function_response=FunctionResponse(
|
|
name=sub_agent1.tools[0].name,
|
|
response={"result": "confirmed=True"},
|
|
)
|
|
),
|
|
),
|
|
(sub_agent1.name, "test llm response after tool call"),
|
|
(sub_agent1.name, testing_utils.END_OF_AGENT),
|
|
]
|
|
|
|
# Resume the invocation again and confirm the tool call from sub_agent2.
|
|
events = await runner.run_async(
|
|
user_confirmations[1], invocation_id=invocation_id
|
|
)
|
|
for event in events:
|
|
assert event.invocation_id == invocation_id
|
|
|
|
# Verify that sub_agent2 is resumed and final; root_agent is final.
|
|
assert testing_utils.simplify_resumable_app_events(
|
|
copy.deepcopy(events)
|
|
) == [
|
|
(
|
|
sub_agent2.name,
|
|
Part(
|
|
function_response=FunctionResponse(
|
|
name=sub_agent2.tools[0].name,
|
|
response={"result": "confirmed=True"},
|
|
)
|
|
),
|
|
),
|
|
(sub_agent2.name, "test llm response after tool call"),
|
|
(sub_agent2.name, testing_utils.END_OF_AGENT),
|
|
(agent.name, testing_utils.END_OF_AGENT),
|
|
]
|