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
482 lines
15 KiB
Python
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))
|