Files
google--adk-python/tests/unittests/agents/test_llm_agent_interruptions.py
wehub-resource-sync ec2b666284
Continuous Integration / Pre-commit Linter (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.10) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.11) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.12) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.12) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Has been cancelled
Copybara PR Handler / close-imported-pr (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:25:13 +08:00

482 lines
15 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.
from __future__ import annotations
from google.adk.agents import LlmAgent
from google.adk.runners import Runner
from google.adk.sessions.in_memory_session_service import InMemorySessionService
from google.adk.tools.function_tool import FunctionTool
from google.adk.tools.long_running_tool import LongRunningFunctionTool
from google.adk.tools.tool_context import ToolContext
from google.genai import types
import pytest
from tests.unittests import testing_utils
from tests.unittests.agents.llm.event_utils import text_parts
_USER_ID = 'test_user'
_SESSION_ID = 'test_session'
async def _setup_runner(mock_model, tools=None, **agent_kwargs):
"""Setup runner with LlmAgent directly."""
llm_agent = LlmAgent(
name='test_agent',
model=mock_model,
tools=tools or [],
**agent_kwargs,
)
session_service = InMemorySessionService()
await session_service.create_session(
app_name='test', user_id=_USER_ID, session_id=_SESSION_ID
)
runner = Runner(
app_name='test',
agent=llm_agent,
session_service=session_service,
)
return runner
async def _run_turn(runner, user_message):
"""Run a single turn."""
return [
e
async for e in runner.run_async(
user_id=_USER_ID,
session_id=_SESSION_ID,
new_message=types.Content(
role='user', parts=[types.Part(text=user_message)]
),
)
]
async def _resume_turn(
runner, prev_events, tool_name, tool_response_value='done'
):
"""Resume after an interrupt."""
fc_ids = []
for e in prev_events:
if e.content and e.content.parts:
for p in e.content.parts:
if (
p.function_call
and p.function_call.name == tool_name
and p.function_call.id
):
fc_ids.append(p.function_call.id)
if getattr(e.output, 'function_calls', None):
for fc in e.output.function_calls:
if fc.name == tool_name and fc.id:
fc_ids.append(fc.id)
if not fc_ids:
for e in prev_events:
if e.long_running_tool_ids:
fc_ids = list(e.long_running_tool_ids)
break
invocation_id = prev_events[0].invocation_id
fr_parts = [
types.Part(
function_response=types.FunctionResponse(
name=tool_name,
id=fc_id,
response={'result': tool_response_value},
)
)
for fc_id in fc_ids
]
resume_msg = types.Content(role='user', parts=fr_parts)
return [
e
async for e in runner.run_async(
user_id=_USER_ID,
session_id=_SESSION_ID,
invocation_id=invocation_id,
new_message=resume_msg,
)
]
def create_lro_tool(name: str = 'long_running_op') -> LongRunningFunctionTool:
"""Creates a minimal LRO tool for testing."""
def _impl() -> None:
return None
_impl.__name__ = name
return LongRunningFunctionTool(_impl)
# ---------------------------------------------------------------------------
# Tests: Single Agent
# ---------------------------------------------------------------------------
class TestSingleAgentInterruptions:
"""Tests for single agent triggering interruptions."""
async def test_single_agent_yields_on_long_running_tool(self):
"""Single agent yields on Long Running Tool.
Arrange: Set up a single agent with a long running tool.
Act: Run the agent with a prompt that triggers the tool.
Assert: Verify that the execution yields a long running tool interrupt.
"""
fc = types.Part.from_function_call(name='long_running_op', args={})
mock_model = testing_utils.MockModel.create(responses=[fc, 'Final answer'])
lro_tool = create_lro_tool()
runner = await _setup_runner(mock_model, tools=[lro_tool])
# Act: Run first turn
events = await _run_turn(runner, 'Go')
# Assert: Should have triggered function call
assert any(
any(
p.function_call and p.function_call.name == 'long_running_op'
for p in e.content.parts or []
)
for e in events
)
assert len(mock_model.requests) == 1
# Act: Resume
resume_events = await _resume_turn(runner, events, 'long_running_op')
# Assert: Should have completed
assert any('Final answer' in t for t in text_parts(resume_events))
assert len(mock_model.requests) == 2
async def test_single_agent_request_input_tool_interrupt_and_resume(self):
"""Test that using RequestInputTool successfully triggers an interrupt and resumes with user input."""
from google.adk.tools import request_input
fc = types.Part.from_function_call(
name='adk_request_input',
args={'message': 'Which file?', 'response_schema': {'type': 'string'}},
)
mock_model = testing_utils.MockModel.create(
responses=[fc, 'Continuing with file: file_a.txt']
)
runner = await _setup_runner(mock_model, tools=[request_input])
# Act: Run first turn
events = await _run_turn(runner, 'Start')
# Assert: Verify the interrupt event is produced
assert any(e.long_running_tool_ids for e in events)
assert any(
any(
p.function_call and p.function_call.name == 'adk_request_input'
for p in e.content.parts or []
)
for e in events
)
# Act: Resume with the response
resume_events = await _resume_turn(
runner, events, 'adk_request_input', tool_response_value='file_a.txt'
)
# Assert: Execution should continue with user response in the prompt history
assert len(mock_model.requests) == 2
# Assert: Verify the second request contains the FunctionCall & FunctionResponse pair
second_req_contents = mock_model.requests[1].contents
assert any(
any(
p.function_call and p.function_call.name == 'adk_request_input'
for p in c.parts or []
)
for c in second_req_contents
)
assert any(
any(
p.function_response
and p.function_response.name == 'adk_request_input'
for p in c.parts or []
)
for c in second_req_contents
)
async def test_single_agent_request_input_tool_structured_schema(self):
"""Test that using RequestInputTool with a structured object schema successfully interrupts and resumes with a dictionary response."""
from google.adk.tools import request_input
schema = {
'type': 'object',
'properties': {
'host': {'type': 'string'},
'port': {'type': 'integer'},
},
'required': ['host'],
}
fc = types.Part.from_function_call(
name='adk_request_input',
args={
'message': 'Provide DB connection details:',
'response_schema': schema,
},
)
mock_model = testing_utils.MockModel.create(
responses=[fc, 'Connected to localhost:3306']
)
runner = await _setup_runner(mock_model, tools=[request_input])
# Act: Run first turn
events = await _run_turn(runner, 'Start')
# Assert: Verify the interrupt event is produced with the schema args
assert any(e.long_running_tool_ids for e in events)
fc_event = next(
e
for e in events
if e.content
and any(
p.function_call and p.function_call.name == 'adk_request_input'
for p in e.content.parts or []
)
)
fc_part = next(p for p in fc_event.content.parts if p.function_call)
assert fc_part.function_call.args['response_schema'] == schema
# Act: Resume with a structured dict response
db_details = {'host': 'localhost', 'port': 3306}
resume_events = await _resume_turn(
runner, events, 'adk_request_input', tool_response_value=db_details
)
# Assert: Execution should continue with the structured user response
assert len(mock_model.requests) == 2
# Assert: Verify the second request contains the FunctionCall & FunctionResponse pair
second_req_contents = mock_model.requests[1].contents
assert any(
any(
p.function_call and p.function_call.name == 'adk_request_input'
for p in c.parts or []
)
for c in second_req_contents
)
assert any(
any(
p.function_response
and p.function_response.name == 'adk_request_input'
for p in c.parts or []
)
for c in second_req_contents
)
class TestNestedAgentInterruptions:
"""Tests for multi-agent setups with interruptions."""
async def test_child_agent_interrupt_and_resume(self):
"""Child agent yields on LRO and resumes successfully.
Arrange: Parent agent with Child agent. Parent transfers to Child.
Child calls LRO tool.
Act: Run, expect LRO interrupt. Then resume.
Assert: Should complete successfully.
"""
def transfer_to_child(tool_context: ToolContext) -> str:
tool_context.actions.transfer_to_agent = 'child_agent'
return 'transferring'
# Child agent
fc_child = types.Part.from_function_call(name='child_lro', args={})
child_mock_model = testing_utils.MockModel.create(
responses=[fc_child, 'Child final answer']
)
lro_tool = create_lro_tool('child_lro')
child_agent = LlmAgent(
name='child_agent',
model=child_mock_model,
tools=[lro_tool],
)
# Parent agent
fc_parent = types.Part.from_function_call(name='transfer_to_child', args={})
parent_mock_model = testing_utils.MockModel.create(
responses=[fc_parent, fc_parent, 'Parent final answer']
)
parent_agent = LlmAgent(
name='parent_agent',
model=parent_mock_model,
tools=[transfer_to_child],
sub_agents=[child_agent],
)
# Setup runner
session_service = InMemorySessionService()
await session_service.create_session(
app_name='test', user_id=_USER_ID, session_id=_SESSION_ID
)
runner = Runner(
app_name='test', agent=parent_agent, session_service=session_service
)
# When Parent runs the first turn
events = await _run_turn(runner, 'Go')
# Then it should trigger child LRO interrupt
assert any(e.long_running_tool_ids for e in events)
# When Parent resumes the turn
resume_events = await _resume_turn(runner, events, 'child_lro')
# Then it should complete successfully
assert any('Child final answer' in t for t in text_parts(resume_events))
@pytest.mark.xfail(reason='Task agent as subagent not supported yet.')
async def test_task_child_agent_interrupt_and_resume(self):
"""Task child agent yields on LRO and resumes successfully.
Arrange: Parent agent with Task Child agent. Parent transfers to Child.
Child calls LRO tool.
Act: Run, expect LRO interrupt. Then resume.
Assert: Should complete successfully.
"""
def transfer_to_child(tool_context: ToolContext) -> str:
tool_context.actions.transfer_to_agent = 'child_agent'
return 'transferring'
# Child agent (Task mode)
fc_child = types.Part.from_function_call(name='child_lro', args={})
fc_finish = types.Part.from_function_call(
name='finish_task', args={'result': 'Task done'}
)
child_mock_model = testing_utils.MockModel.create(
responses=[fc_child, fc_finish, 'Child final answer']
)
lro_tool = create_lro_tool('child_lro')
child_agent = LlmAgent(
name='child_agent',
model=child_mock_model,
tools=[lro_tool],
mode='task',
)
# Parent agent
fc_parent = types.Part.from_function_call(name='transfer_to_child', args={})
parent_mock_model = testing_utils.MockModel.create(
responses=[fc_parent, 'Parent final answer']
)
parent_agent = LlmAgent(
name='parent_agent',
model=parent_mock_model,
tools=[transfer_to_child],
sub_agents=[child_agent],
)
# Setup runner
session_service = InMemorySessionService()
await session_service.create_session(
app_name='test', user_id=_USER_ID, session_id=_SESSION_ID
)
runner = Runner(
app_name='test', agent=parent_agent, session_service=session_service
)
# When Parent runs the first turn
events = await _run_turn(runner, 'Go')
# Then it should trigger child LRO interrupt
assert any(e.long_running_tool_ids for e in events)
# When Parent resumes the turn
resume_events = await _resume_turn(runner, events, 'child_lro')
# Then it should complete successfully
assert any('Parent final answer' in t for t in text_parts(resume_events))
@pytest.mark.xfail(reason='Single-turn agent as subagent not supported yet.')
async def test_single_turn_child_agent_interrupt_and_resume(self):
"""Single-turn child agent yields on LRO and resumes successfully.
Arrange: Parent agent with Single-turn Child agent.
Parent calls Child via tool.
Child calls LRO tool.
Act: Run, expect LRO interrupt. Then resume.
Assert: Should complete successfully.
"""
# Child agent (Single-turn)
fc_child = types.Part.from_function_call(name='child_lro', args={})
child_mock_model = testing_utils.MockModel.create(
responses=[fc_child, 'Child final answer']
)
lro_tool = create_lro_tool('child_lro')
child_agent = LlmAgent(
name='child_agent',
model=child_mock_model,
tools=[lro_tool],
mode='single_turn',
)
# Parent agent
fc_call_child = types.Part.from_function_call(
name='child_agent', args={'request': 'Go to child'}
)
parent_mock_model = testing_utils.MockModel.create(
responses=[fc_call_child, 'Parent final answer']
)
parent_agent = LlmAgent(
name='parent_agent',
model=parent_mock_model,
sub_agents=[child_agent],
)
# Setup runner
session_service = InMemorySessionService()
await session_service.create_session(
app_name='test', user_id=_USER_ID, session_id=_SESSION_ID
)
runner = Runner(
app_name='test', agent=parent_agent, session_service=session_service
)
# When Parent runs the first turn
events = await _run_turn(runner, 'Go')
# Then it should trigger child LRO interrupt
assert any(e.long_running_tool_ids for e in events)
# When Parent resumes the turn
resume_events = await _resume_turn(runner, events, 'child_lro')
# Then it should complete successfully
assert any('Parent final answer' in t for t in text_parts(resume_events))