chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:25:13 +08:00
commit ec2b666284
2231 changed files with 491535 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
# 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.
+13
View File
@@ -0,0 +1,13 @@
# 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.
+81
View File
@@ -0,0 +1,81 @@
# 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.
"""Shared test helpers for extracting data from Event lists."""
from __future__ import annotations
from typing import Any
from google.adk.events.event import Event
async def collect_events(node, ctx, node_input=None) -> list[Event]:
"""Collect all events yielded by node.run()."""
events = []
async for event in node.run(ctx=ctx, node_input=node_input):
events.append(event)
return events
def output_events(events: list[Event]) -> list[Event]:
"""Filter to events that carry output."""
return [e for e in events if e.output is not None]
def content_events(events: list[Event]) -> list[Event]:
"""Filter to events that have content."""
return [e for e in events if e.content and e.content.parts]
def text_parts(events: list[Event]) -> list[str]:
"""Extract text strings from content events."""
return [
p.text.strip()
for e in content_events(events)
for p in e.content.parts
if p.text
]
def function_call_names(events: list[Event]) -> list[str]:
"""Extract function call names from content events."""
return [
p.function_call.name
for e in content_events(events)
for p in e.content.parts
if p.function_call
]
def function_response_dicts(events: list[Event]) -> list[dict[str, Any]]:
"""Extract function response dicts from events."""
return [
dict(p.function_response.response or {})
for e in content_events(events)
for p in e.content.parts
if p.function_response
]
def function_responses_by_name(
events: list[Event],
) -> dict[str, dict[str, Any]]:
"""Extract {tool_name: response_dict} from function response events."""
return {
p.function_response.name: dict(p.function_response.response or {})
for e in content_events(events)
for p in e.content.parts
if p.function_response
}
@@ -0,0 +1,13 @@
# 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.
@@ -0,0 +1,363 @@
# 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 the finish_task_tool module."""
from __future__ import annotations
from typing import Any
from unittest import mock
from google.adk.agents.llm.task._finish_task_tool import FINISH_TASK_TOOL_NAME
from google.adk.agents.llm.task._finish_task_tool import FinishTaskTool
from pydantic import BaseModel
import pytest
class SampleOutputSchema(BaseModel):
"""Sample Pydantic model for testing output schema."""
result: str
count: int
class NestedOutputSchema(BaseModel):
"""Nested Pydantic model for testing complex schemas."""
name: str
details: dict
def _make_task_agent(
name='test_agent',
mode='task',
output_schema=None,
):
"""Create a mock task agent for FinishTaskTool construction."""
agent = mock.MagicMock()
agent.name = name
agent.mode = mode
agent.output_schema = output_schema
return agent
class TestFinishTaskTool:
"""Tests for the FinishTaskTool class."""
def test_init_without_output_schema(self):
"""Test FinishTaskTool initialization without output schema."""
agent = _make_task_agent()
tool = FinishTaskTool(task_agent=agent)
assert tool.name == FINISH_TASK_TOOL_NAME
assert 'Signal that this agent has completed' in tool.description
assert 'output data' not in tool.description
def test_init_with_output_schema(self):
"""Test FinishTaskTool initialization with output schema."""
agent = _make_task_agent(output_schema=SampleOutputSchema)
tool = FinishTaskTool(task_agent=agent)
assert tool.name == FINISH_TASK_TOOL_NAME
assert tool.output_schema is SampleOutputSchema
assert 'Signal that this agent has completed' in tool.description
assert 'output data' in tool.description
def test_get_declaration_without_output_schema(self):
"""Test function declaration generation without output schema."""
agent = _make_task_agent()
tool = FinishTaskTool(task_agent=agent)
declaration = tool._get_declaration()
assert declaration is not None
assert declaration.name == FINISH_TASK_TOOL_NAME
assert declaration.parameters_json_schema is not None
schema = declaration.parameters_json_schema
assert 'result' in schema.get('properties', {})
def test_get_declaration_with_output_schema(self):
"""Test function declaration generation with output schema."""
agent = _make_task_agent(output_schema=SampleOutputSchema)
tool = FinishTaskTool(task_agent=agent)
declaration = tool._get_declaration()
assert declaration is not None
assert declaration.name == FINISH_TASK_TOOL_NAME
assert declaration.parameters_json_schema is not None
schema = declaration.parameters_json_schema
assert 'result' in schema.get('properties', {})
assert 'count' in schema.get('properties', {})
@pytest.mark.asyncio
async def test_run_async_returns_confirmation(self):
"""Test that run_async returns a confirmation message."""
agent = _make_task_agent()
tool = FinishTaskTool(task_agent=agent)
mock_tool_context = mock.MagicMock()
result = await tool.run_async(
args={'result': 'done'}, tool_context=mock_tool_context
)
assert result == 'Task completed.'
@pytest.mark.asyncio
async def test_run_async_with_args(self):
"""Test that run_async validates args and returns confirmation."""
agent = _make_task_agent(output_schema=SampleOutputSchema)
tool = FinishTaskTool(task_agent=agent)
mock_tool_context = mock.MagicMock()
result = await tool.run_async(
args={'result': 'success', 'count': 42},
tool_context=mock_tool_context,
)
assert result == 'Task completed.'
class TestBuildInstruction:
"""Tests for the _build_instruction method."""
def test_instruction_content(self):
"""Test instruction generation contains expected content."""
agent = _make_task_agent()
tool = FinishTaskTool(task_agent=agent)
instruction = tool._build_instruction()
assert 'finish_task' in instruction
assert 'Do NOT call `finish_task` prematurely' in instruction
assert 'call `finish_task` by itself with' in instruction
class TestProcessLlmRequest:
"""Tests for the process_llm_request method."""
@pytest.mark.asyncio
async def test_process_llm_request_adds_tool_and_instruction(self):
"""Test that process_llm_request adds tool and instruction."""
agent = _make_task_agent()
tool = FinishTaskTool(task_agent=agent)
mock_tool_context = mock.MagicMock()
mock_tool_context._invocation_context.branch = None
mock_tool_context.session.events = []
mock_llm_request = mock.MagicMock()
mock_llm_request.append_tools = mock.MagicMock()
mock_llm_request.append_instructions = mock.MagicMock()
await tool.process_llm_request(
tool_context=mock_tool_context, llm_request=mock_llm_request
)
# Should add tool via parent's process_llm_request
mock_llm_request.append_tools.assert_called_once_with([tool])
# Should append instruction (at least the base instruction)
mock_llm_request.append_instructions.assert_called()
instruction_arg = mock_llm_request.append_instructions.call_args_list[0][0][
0
]
assert len(instruction_arg) == 1
assert 'finish_task' in instruction_arg[0]
class TestFinishTaskToolName:
"""Tests for the FINISH_TASK_TOOL_NAME constant."""
def test_constant_value(self):
"""Test that the constant has the expected value."""
assert FINISH_TASK_TOOL_NAME == 'finish_task'
class TestFinishTaskToolValidation:
"""Tests for the FinishTaskTool argument validation."""
@pytest.mark.asyncio
async def test_run_async_validation_error_missing_required_field(self):
"""Test that validation error is returned when required fields are missing."""
agent = _make_task_agent(output_schema=SampleOutputSchema)
tool = FinishTaskTool(task_agent=agent)
mock_tool_context = mock.MagicMock()
# Missing 'count' field which is required
result = await tool.run_async(
args={'result': 'success'},
tool_context=mock_tool_context,
)
assert isinstance(result, dict)
assert 'error' in result
assert 'finish_task' in result['error']
assert 'validation errors' in result['error']
assert 'count' in result['error']
@pytest.mark.asyncio
async def test_run_async_validation_error_wrong_type(self):
"""Test that validation error is returned when types are wrong."""
agent = _make_task_agent(output_schema=SampleOutputSchema)
tool = FinishTaskTool(task_agent=agent)
mock_tool_context = mock.MagicMock()
# 'count' should be int, not string
result = await tool.run_async(
args={'result': 'success', 'count': 'not_an_int'},
tool_context=mock_tool_context,
)
assert isinstance(result, dict)
assert 'error' in result
assert 'validation errors' in result['error']
@pytest.mark.asyncio
async def test_run_async_validation_passes_with_valid_args(self):
"""Test that validation passes with valid args."""
agent = _make_task_agent(output_schema=SampleOutputSchema)
tool = FinishTaskTool(task_agent=agent)
mock_tool_context = mock.MagicMock()
result = await tool.run_async(
args={'result': 'success', 'count': 42},
tool_context=mock_tool_context,
)
assert result == 'Task completed.'
class TestFinishTaskToolAllSchemaTypes:
"""Tests for FinishTaskTool across all supported SchemaType variants.
Object schemas (BaseModel, dict) use parameters directly.
Non-object schemas (str, int, bool, float, list) are wrapped in a
JSON object with a 'result' key so they can be used as function
parameters.
"""
@pytest.mark.parametrize(
'output_schema, expected_wrapper_key',
[
(SampleOutputSchema, None),
(dict[str, Any], None),
(str, 'result'),
(int, 'result'),
(bool, 'result'),
(float, 'result'),
(list[str], 'result'),
(list[int], 'result'),
(list[SampleOutputSchema], 'result'),
],
ids=[
'BaseModel',
'dict',
'str',
'int',
'bool',
'float',
'list_str',
'list_int',
'list_BaseModel',
],
)
def test_wrapper_key(self, output_schema, expected_wrapper_key):
"""Verify wrapper key is set correctly for each schema type."""
agent = _make_task_agent(output_schema=output_schema)
tool = FinishTaskTool(task_agent=agent)
assert tool._wrapper_key == expected_wrapper_key
@pytest.mark.parametrize(
'output_schema',
[str, int, bool, float, list[str], list[int], list[SampleOutputSchema]],
ids=[
'str',
'int',
'bool',
'float',
'list_str',
'list_int',
'list_BaseModel',
],
)
def test_get_declaration_wrapped_schema(self, output_schema):
"""Non-object schemas produce a declaration with 'result' property."""
agent = _make_task_agent(output_schema=output_schema)
tool = FinishTaskTool(task_agent=agent)
declaration = tool._get_declaration()
assert declaration is not None
assert declaration.name == FINISH_TASK_TOOL_NAME
schema = declaration.parameters_json_schema
assert schema is not None
assert 'result' in schema.get('properties', {})
@pytest.mark.parametrize(
'output_schema, args, expected_output',
[
(
SampleOutputSchema,
{'result': 'done', 'count': 5},
{'result': 'done', 'count': 5},
),
(dict[str, Any], {'key': 'value'}, {'key': 'value'}),
(str, {'result': 'hello'}, 'hello'),
(int, {'result': 42}, 42),
(bool, {'result': True}, True),
(float, {'result': 3.14}, 3.14),
(list[str], {'result': ['a', 'b', 'c']}, ['a', 'b', 'c']),
(list[int], {'result': [1, 2, 3]}, [1, 2, 3]),
(
list[SampleOutputSchema],
{'result': [{'result': 'ok', 'count': 1}]},
[{'result': 'ok', 'count': 1}],
),
(list[str], {'result': []}, []),
],
ids=[
'BaseModel',
'dict',
'str',
'int',
'bool',
'float',
'list_str',
'list_int',
'list_BaseModel',
'list_str_empty',
],
)
@pytest.mark.asyncio
async def test_run_async(self, output_schema, args, expected_output):
"""Verify run_async validates and extracts output for each schema type."""
agent = _make_task_agent(output_schema=output_schema)
tool = FinishTaskTool(task_agent=agent)
mock_tool_context = mock.MagicMock()
result = await tool.run_async(
args=args,
tool_context=mock_tool_context,
)
# Task Delegation API: tool no longer writes actions.finish_task. The
# LlmAgent task wrapper sniffs the FC's `output` arg directly to
# set event.output. The tool just performs schema validation.
assert result == 'Task completed.'
del expected_output # validated_output is no longer surfaced via actions
def test_get_declaration_list_basemodel_defs_at_root(self):
"""Non-object schemas with $defs should hoist $defs to root level."""
agent = _make_task_agent(output_schema=list[SampleOutputSchema])
tool = FinishTaskTool(task_agent=agent)
declaration = tool._get_declaration()
schema = declaration.parameters_json_schema
# $defs must be at the root, not nested inside properties.result
assert '$defs' in schema
assert 'SampleOutputSchema' in schema['$defs']
# The nested result schema should not contain $defs
assert '$defs' not in schema['properties']['result']
+573
View File
@@ -0,0 +1,573 @@
# 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.
"""Testings for the clone functionality of agents."""
from typing import Any
from typing import cast
from typing import Iterable
from google.adk.agents.llm_agent import LlmAgent
from google.adk.agents.loop_agent import LoopAgent
from google.adk.agents.parallel_agent import ParallelAgent
from google.adk.agents.sequential_agent import SequentialAgent
import pytest
def test_llm_agent_clone():
"""Test cloning an LLM agent."""
# Create an LLM agent
original = LlmAgent(
name="llm_agent",
description="An LLM agent",
instruction="You are a helpful assistant.",
)
# Clone it with name update
cloned = original.clone(update={"name": "cloned_llm_agent"})
# Verify the clone
assert cloned.name == "cloned_llm_agent"
assert cloned.description == "An LLM agent"
assert cloned.instruction == "You are a helpful assistant."
assert cloned.parent_agent is None
assert len(cloned.sub_agents) == 0
assert isinstance(cloned, LlmAgent)
# Verify the original is unchanged
assert original.name == "llm_agent"
assert original.instruction == "You are a helpful assistant."
def test_agent_with_sub_agents():
"""Test cloning an agent that has sub-agents."""
# Create sub-agents
sub_agent1 = LlmAgent(name="sub_agent1", description="First sub-agent")
sub_agent2 = LlmAgent(name="sub_agent2", description="Second sub-agent")
# Create a parent agent with sub-agents
original = SequentialAgent(
name="parent_agent",
description="Parent agent with sub-agents",
sub_agents=[sub_agent1, sub_agent2],
)
# Clone it with name update
cloned = original.clone(update={"name": "cloned_parent"})
# Verify the clone has sub-agents (deep copy behavior)
assert cloned.name == "cloned_parent"
assert cloned.description == "Parent agent with sub-agents"
assert cloned.parent_agent is None
assert len(cloned.sub_agents) == 2
# Sub-agents should be cloned with their original names
assert cloned.sub_agents[0].name == "sub_agent1"
assert cloned.sub_agents[1].name == "sub_agent2"
# Sub-agents should have the cloned agent as their parent
assert cloned.sub_agents[0].parent_agent == cloned
assert cloned.sub_agents[1].parent_agent == cloned
# Sub-agents should be different objects from the original
assert cloned.sub_agents[0] is not original.sub_agents[0]
assert cloned.sub_agents[1] is not original.sub_agents[1]
# Verify the original still has sub-agents
assert original.name == "parent_agent"
assert len(original.sub_agents) == 2
assert original.sub_agents[0].name == "sub_agent1"
assert original.sub_agents[1].name == "sub_agent2"
assert original.sub_agents[0].parent_agent == original
assert original.sub_agents[1].parent_agent == original
def test_three_level_nested_agent():
"""Test cloning a three-level nested agent to verify recursive cloning logic."""
# Create third-level agents (leaf nodes)
leaf_agent1 = LlmAgent(name="leaf1", description="First leaf agent")
leaf_agent2 = LlmAgent(name="leaf2", description="Second leaf agent")
# Create second-level agents
middle_agent1 = SequentialAgent(
name="middle1", description="First middle agent", sub_agents=[leaf_agent1]
)
middle_agent2 = ParallelAgent(
name="middle2",
description="Second middle agent",
sub_agents=[leaf_agent2],
)
# Create top-level agent
root_agent = LoopAgent(
name="root_agent",
description="Root agent with three levels",
max_iterations=5,
sub_agents=[middle_agent1, middle_agent2],
)
# Clone the root agent
cloned_root = root_agent.clone(update={"name": "cloned_root"})
# Verify root level
assert cloned_root.name == "cloned_root"
assert cloned_root.description == "Root agent with three levels"
assert cloned_root.max_iterations == 5
assert cloned_root.parent_agent is None
assert len(cloned_root.sub_agents) == 2
assert isinstance(cloned_root, LoopAgent)
# Verify middle level
cloned_middle1 = cloned_root.sub_agents[0]
cloned_middle2 = cloned_root.sub_agents[1]
assert cloned_middle1.name == "middle1"
assert cloned_middle1.description == "First middle agent"
assert cloned_middle1.parent_agent == cloned_root
assert len(cloned_middle1.sub_agents) == 1
assert isinstance(cloned_middle1, SequentialAgent)
assert cloned_middle2.name == "middle2"
assert cloned_middle2.description == "Second middle agent"
assert cloned_middle2.parent_agent == cloned_root
assert len(cloned_middle2.sub_agents) == 1
assert isinstance(cloned_middle2, ParallelAgent)
# Verify leaf level
cloned_leaf1 = cloned_middle1.sub_agents[0]
cloned_leaf2 = cloned_middle2.sub_agents[0]
assert cloned_leaf1.name == "leaf1"
assert cloned_leaf1.description == "First leaf agent"
assert cloned_leaf1.parent_agent == cloned_middle1
assert len(cloned_leaf1.sub_agents) == 0
assert isinstance(cloned_leaf1, LlmAgent)
assert cloned_leaf2.name == "leaf2"
assert cloned_leaf2.description == "Second leaf agent"
assert cloned_leaf2.parent_agent == cloned_middle2
assert len(cloned_leaf2.sub_agents) == 0
assert isinstance(cloned_leaf2, LlmAgent)
# Verify all objects are different from originals
assert cloned_root is not root_agent
assert cloned_middle1 is not middle_agent1
assert cloned_middle2 is not middle_agent2
assert cloned_leaf1 is not leaf_agent1
assert cloned_leaf2 is not leaf_agent2
# Verify original structure is unchanged
assert root_agent.name == "root_agent"
assert root_agent.sub_agents[0].name == "middle1"
assert root_agent.sub_agents[1].name == "middle2"
assert root_agent.sub_agents[0].sub_agents[0].name == "leaf1"
assert root_agent.sub_agents[1].sub_agents[0].name == "leaf2"
def test_multiple_clones():
"""Test creating multiple clones with automatic naming."""
# Create multiple agents and clone each one
original = LlmAgent(
name="original_agent", description="Agent for multiple cloning"
)
# Test multiple clones from the same original
clone1 = original.clone(update={"name": "clone1"})
clone2 = original.clone(update={"name": "clone2"})
assert clone1.name == "clone1"
assert clone2.name == "clone2"
assert clone1 is not clone2
def test_clone_with_complex_configuration():
"""Test cloning an agent with complex configuration."""
# Create an LLM agent with various configurations
original = LlmAgent(
name="complex_agent",
description="A complex agent with many settings",
instruction="You are a specialized assistant.",
global_instruction="Always be helpful and accurate.",
disallow_transfer_to_parent=True,
disallow_transfer_to_peers=True,
include_contents="none",
)
# Clone it with name update
cloned = original.clone(update={"name": "complex_clone"})
# Verify all configurations are preserved
assert cloned.name == "complex_clone"
assert cloned.description == "A complex agent with many settings"
assert cloned.instruction == "You are a specialized assistant."
assert cloned.global_instruction == "Always be helpful and accurate."
assert cloned.disallow_transfer_to_parent is True
assert cloned.disallow_transfer_to_peers is True
assert cloned.include_contents == "none"
# Verify parent and sub-agents are set
assert cloned.parent_agent is None
assert len(cloned.sub_agents) == 0
def test_clone_without_updates():
"""Test cloning without providing updates (should use original values)."""
original = LlmAgent(name="test_agent", description="Test agent")
cloned = original.clone()
assert cloned.name == "test_agent"
assert cloned.description == "Test agent"
def test_clone_with_multiple_updates():
"""Test cloning with multiple field updates."""
original = LlmAgent(
name="original_agent",
description="Original description",
instruction="Original instruction",
)
cloned = original.clone(
update={
"name": "updated_agent",
"description": "Updated description",
"instruction": "Updated instruction",
}
)
assert cloned.name == "updated_agent"
assert cloned.description == "Updated description"
assert cloned.instruction == "Updated instruction"
def test_clone_with_sub_agents_deep_copy():
"""Test cloning with deep copy of sub-agents."""
# Create an agent with sub-agents
sub_agent = LlmAgent(name="sub_agent", description="Sub agent")
original = LlmAgent(
name="root_agent",
description="Root agent",
sub_agents=[sub_agent],
)
# Clone with deep copy
cloned = original.clone(update={"name": "cloned_root_agent"})
assert cloned.name == "cloned_root_agent"
assert cloned.sub_agents[0].name == "sub_agent"
assert cloned.sub_agents[0].parent_agent == cloned
assert cloned.sub_agents[0] is not original.sub_agents[0]
def test_clone_invalid_field():
"""Test that cloning with invalid fields raises an error."""
original = LlmAgent(name="test_agent", description="Test agent")
with pytest.raises(ValueError, match="Cannot update nonexistent fields"):
original.clone(update={"invalid_field": "value"})
def test_clone_parent_agent_field():
"""Test that cloning with parent_agent field raises an error."""
original = LlmAgent(name="test_agent", description="Test agent")
with pytest.raises(
ValueError, match="Cannot update `parent_agent` field in clone"
):
original.clone(update={"parent_agent": None})
def test_clone_preserves_agent_type():
"""Test that cloning preserves the specific agent type."""
# Test LlmAgent
llm_original = LlmAgent(name="llm_test")
llm_cloned = llm_original.clone()
assert isinstance(llm_cloned, LlmAgent)
# Test SequentialAgent
seq_original = SequentialAgent(
name="seq_test", sub_agents=[LlmAgent(name="dummy_seq")]
)
seq_cloned = seq_original.clone()
assert isinstance(seq_cloned, SequentialAgent)
# Test ParallelAgent
par_original = ParallelAgent(
name="par_test", sub_agents=[LlmAgent(name="dummy_par")]
)
par_cloned = par_original.clone()
assert isinstance(par_cloned, ParallelAgent)
# Test LoopAgent
loop_original = LoopAgent(
name="loop_test", sub_agents=[LlmAgent(name="dummy_loop")]
)
loop_cloned = loop_original.clone()
assert isinstance(loop_cloned, LoopAgent)
def test_clone_with_agent_specific_fields():
# Test LoopAgent
dummy = LlmAgent(name="dummy")
loop_original = LoopAgent(name="loop_test", sub_agents=[dummy])
loop_cloned = loop_original.clone({"max_iterations": 10})
assert isinstance(loop_cloned, LoopAgent)
assert loop_cloned.max_iterations == 10
def test_clone_with_none_update():
"""Test cloning with explicit None update parameter."""
original = LlmAgent(name="test_agent", description="Test agent")
cloned = original.clone(update=None)
assert cloned.name == "test_agent"
assert cloned.description == "Test agent"
assert cloned is not original
def test_clone_with_empty_update():
"""Test cloning with empty update dictionary."""
original = LlmAgent(name="test_agent", description="Test agent")
cloned = original.clone(update={})
assert cloned.name == "test_agent"
assert cloned.description == "Test agent"
assert cloned is not original
def test_clone_with_sub_agents_update():
"""Test cloning with sub_agents provided in update."""
# Create original sub-agents
original_sub1 = LlmAgent(name="original_sub1", description="Original sub 1")
original_sub2 = LlmAgent(name="original_sub2", description="Original sub 2")
# Create new sub-agents for the update
new_sub1 = LlmAgent(name="new_sub1", description="New sub 1")
new_sub2 = LlmAgent(name="new_sub2", description="New sub 2")
# Create original agent with sub-agents
original = SequentialAgent(
name="original_agent",
description="Original agent",
sub_agents=[original_sub1, original_sub2],
)
# Clone with sub_agents update
cloned = original.clone(
update={"name": "cloned_agent", "sub_agents": [new_sub1, new_sub2]}
)
# Verify the clone uses the new sub-agents
assert cloned.name == "cloned_agent"
assert len(cloned.sub_agents) == 2
assert cloned.sub_agents[0].name == "new_sub1"
assert cloned.sub_agents[1].name == "new_sub2"
assert cloned.sub_agents[0].parent_agent == cloned
assert cloned.sub_agents[1].parent_agent == cloned
# Verify original is unchanged
assert original.name == "original_agent"
assert len(original.sub_agents) == 2
assert original.sub_agents[0].name == "original_sub1"
assert original.sub_agents[1].name == "original_sub2"
def _check_lists_contain_same_contents(*lists: Iterable[list[Any]]) -> None:
"""Assert that all provided lists contain the same elements."""
if lists:
first_list = lists[0]
assert all(len(lst) == len(first_list) for lst in lists)
for idx, elem in enumerate(first_list):
assert all(lst[idx] is elem for lst in lists)
def test_clone_shallow_copies_lists():
"""Test that cloning shallow copies fields stored as lists."""
# Define the list fields
before_agent_callback = [lambda *args, **kwargs: None]
after_agent_callback = [lambda *args, **kwargs: None]
before_model_callback = [lambda *args, **kwargs: None]
after_model_callback = [lambda *args, **kwargs: None]
before_tool_callback = [lambda *args, **kwargs: None]
after_tool_callback = [lambda *args, **kwargs: None]
tools = [lambda *args, **kwargs: None]
# Create the original agent with list fields
original = LlmAgent(
name="original_agent",
description="Original agent",
before_agent_callback=before_agent_callback,
after_agent_callback=after_agent_callback,
before_model_callback=before_model_callback,
after_model_callback=after_model_callback,
before_tool_callback=before_tool_callback,
after_tool_callback=after_tool_callback,
tools=tools,
)
# Clone the agent
cloned = original.clone()
# Verify the lists are copied
assert original.before_agent_callback is not cloned.before_agent_callback
assert original.after_agent_callback is not cloned.after_agent_callback
assert original.before_model_callback is not cloned.before_model_callback
assert original.after_model_callback is not cloned.after_model_callback
assert original.before_tool_callback is not cloned.before_tool_callback
assert original.after_tool_callback is not cloned.after_tool_callback
assert original.tools is not cloned.tools
# Verify the list copies are shallow
_check_lists_contain_same_contents(
before_agent_callback,
original.before_agent_callback,
cloned.before_agent_callback,
)
_check_lists_contain_same_contents(
after_agent_callback,
original.after_agent_callback,
cloned.after_agent_callback,
)
_check_lists_contain_same_contents(
before_model_callback,
original.before_model_callback,
cloned.before_model_callback,
)
_check_lists_contain_same_contents(
after_model_callback,
original.after_model_callback,
cloned.after_model_callback,
)
_check_lists_contain_same_contents(
before_tool_callback,
original.before_tool_callback,
cloned.before_tool_callback,
)
_check_lists_contain_same_contents(
after_tool_callback,
original.after_tool_callback,
cloned.after_tool_callback,
)
_check_lists_contain_same_contents(tools, original.tools, cloned.tools)
def test_clone_shallow_copies_lists_with_sub_agents():
"""Test that cloning recursively shallow copies fields stored as lists."""
# Define the list fields for the sub-agent
before_agent_callback = [lambda *args, **kwargs: None]
after_agent_callback = [lambda *args, **kwargs: None]
before_model_callback = [lambda *args, **kwargs: None]
after_model_callback = [lambda *args, **kwargs: None]
before_tool_callback = [lambda *args, **kwargs: None]
after_tool_callback = [lambda *args, **kwargs: None]
tools = [lambda *args, **kwargs: None]
# Create the original sub-agent with list fields and the top-level agent
sub_agents = [
LlmAgent(
name="sub_agent",
description="Sub agent",
before_agent_callback=before_agent_callback,
after_agent_callback=after_agent_callback,
before_model_callback=before_model_callback,
after_model_callback=after_model_callback,
before_tool_callback=before_tool_callback,
after_tool_callback=after_tool_callback,
tools=tools,
)
]
original = LlmAgent(
name="original_agent",
description="Original agent",
sub_agents=sub_agents,
)
# Clone the top-level agent
cloned = original.clone()
# Verify the sub_agents list is copied for the top-level agent
assert original.sub_agents is not cloned.sub_agents
# Retrieve the sub-agent for the original and cloned top-level agent
original_sub_agent = cast(LlmAgent, original.sub_agents[0])
cloned_sub_agent = cast(LlmAgent, cloned.sub_agents[0])
# Verify the lists are copied for the sub-agent
assert (
original_sub_agent.before_agent_callback
is not cloned_sub_agent.before_agent_callback
)
assert (
original_sub_agent.after_agent_callback
is not cloned_sub_agent.after_agent_callback
)
assert (
original_sub_agent.before_model_callback
is not cloned_sub_agent.before_model_callback
)
assert (
original_sub_agent.after_model_callback
is not cloned_sub_agent.after_model_callback
)
assert (
original_sub_agent.before_tool_callback
is not cloned_sub_agent.before_tool_callback
)
assert (
original_sub_agent.after_tool_callback
is not cloned_sub_agent.after_tool_callback
)
assert original_sub_agent.tools is not cloned_sub_agent.tools
# Verify the list copies are shallow for the sub-agent
_check_lists_contain_same_contents(
before_agent_callback,
original_sub_agent.before_agent_callback,
cloned_sub_agent.before_agent_callback,
)
_check_lists_contain_same_contents(
after_agent_callback,
original_sub_agent.after_agent_callback,
cloned_sub_agent.after_agent_callback,
)
_check_lists_contain_same_contents(
before_model_callback,
original_sub_agent.before_model_callback,
cloned_sub_agent.before_model_callback,
)
_check_lists_contain_same_contents(
after_model_callback,
original_sub_agent.after_model_callback,
cloned_sub_agent.after_model_callback,
)
_check_lists_contain_same_contents(
before_tool_callback,
original_sub_agent.before_tool_callback,
cloned_sub_agent.before_tool_callback,
)
_check_lists_contain_same_contents(
after_tool_callback,
original_sub_agent.after_tool_callback,
cloned_sub_agent.after_tool_callback,
)
_check_lists_contain_same_contents(
tools, original_sub_agent.tools, cloned_sub_agent.tools
)
if __name__ == "__main__":
# Run a specific test for debugging
test_three_level_nested_agent()
+618
View File
@@ -0,0 +1,618 @@
# 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.
import ntpath
import os
from pathlib import Path
from textwrap import dedent
from typing import Literal
from typing import Type
from unittest import mock
from google.adk.agents import config_agent_utils
from google.adk.agents.agent_config import AgentConfig
from google.adk.agents.base_agent import BaseAgent
from google.adk.agents.base_agent_config import BaseAgentConfig
from google.adk.agents.common_configs import AgentRefConfig
from google.adk.agents.llm_agent import LlmAgent
from google.adk.agents.loop_agent import LoopAgent
from google.adk.agents.parallel_agent import ParallelAgent
from google.adk.agents.sequential_agent import SequentialAgent
from google.adk.models.lite_llm import LiteLlm
import pytest
import yaml
def test_agent_config_discriminator_default_is_llm_agent(tmp_path: Path):
yaml_content = """\
name: search_agent
model: gemini-2.5-flash
description: a sample description
instruction: a fake instruction
tools:
- name: google_search
"""
config_file = tmp_path / "test_config.yaml"
config_file.write_text(yaml_content)
config = AgentConfig.model_validate(yaml.safe_load(yaml_content))
agent = config_agent_utils.from_config(str(config_file))
assert isinstance(agent, LlmAgent)
assert config.root.agent_class == "LlmAgent"
@pytest.mark.parametrize(
"agent_class_value",
[
"LlmAgent",
"google.adk.agents.LlmAgent",
"google.adk.agents.llm_agent.LlmAgent",
],
)
def test_agent_config_discriminator_llm_agent(
agent_class_value: str, tmp_path: Path
):
yaml_content = f"""\
agent_class: {agent_class_value}
name: search_agent
model: gemini-2.5-flash
description: a sample description
instruction: a fake instruction
tools:
- name: google_search
"""
config_file = tmp_path / "test_config.yaml"
config_file.write_text(yaml_content)
config = AgentConfig.model_validate(yaml.safe_load(yaml_content))
agent = config_agent_utils.from_config(str(config_file))
assert isinstance(agent, LlmAgent)
assert config.root.agent_class == agent_class_value
@pytest.mark.parametrize(
"agent_class_value",
[
"LoopAgent",
"google.adk.agents.LoopAgent",
"google.adk.agents.loop_agent.LoopAgent",
],
)
def test_agent_config_discriminator_loop_agent(
agent_class_value: str, tmp_path: Path
):
yaml_content = f"""\
agent_class: {agent_class_value}
name: CodePipelineAgent
description: Executes a sequence of code writing, reviewing, and refactoring.
sub_agents: []
"""
config_file = tmp_path / "test_config.yaml"
config_file.write_text(yaml_content)
config = AgentConfig.model_validate(yaml.safe_load(yaml_content))
agent = config_agent_utils.from_config(str(config_file))
assert isinstance(agent, LoopAgent)
assert config.root.agent_class == agent_class_value
@pytest.mark.parametrize(
"agent_class_value",
[
"ParallelAgent",
"google.adk.agents.ParallelAgent",
"google.adk.agents.parallel_agent.ParallelAgent",
],
)
def test_agent_config_discriminator_parallel_agent(
agent_class_value: str, tmp_path: Path
):
yaml_content = f"""\
agent_class: {agent_class_value}
name: CodePipelineAgent
description: Executes a sequence of code writing, reviewing, and refactoring.
sub_agents: []
"""
config_file = tmp_path / "test_config.yaml"
config_file.write_text(yaml_content)
config = AgentConfig.model_validate(yaml.safe_load(yaml_content))
agent = config_agent_utils.from_config(str(config_file))
assert isinstance(agent, ParallelAgent)
assert config.root.agent_class == agent_class_value
@pytest.mark.parametrize(
"agent_class_value",
[
"SequentialAgent",
"google.adk.agents.SequentialAgent",
"google.adk.agents.sequential_agent.SequentialAgent",
],
)
def test_agent_config_discriminator_sequential_agent(
agent_class_value: str, tmp_path: Path
):
yaml_content = f"""\
agent_class: {agent_class_value}
name: CodePipelineAgent
description: Executes a sequence of code writing, reviewing, and refactoring.
sub_agents: []
"""
config_file = tmp_path / "test_config.yaml"
config_file.write_text(yaml_content)
config = AgentConfig.model_validate(yaml.safe_load(yaml_content))
agent = config_agent_utils.from_config(str(config_file))
assert isinstance(agent, SequentialAgent)
assert config.root.agent_class == agent_class_value
@pytest.mark.parametrize(
("agent_class_value", "expected_agent_type"),
[
("LoopAgent", LoopAgent),
("google.adk.agents.LoopAgent", LoopAgent),
("google.adk.agents.loop_agent.LoopAgent", LoopAgent),
("ParallelAgent", ParallelAgent),
("google.adk.agents.ParallelAgent", ParallelAgent),
("google.adk.agents.parallel_agent.ParallelAgent", ParallelAgent),
("SequentialAgent", SequentialAgent),
("google.adk.agents.SequentialAgent", SequentialAgent),
("google.adk.agents.sequential_agent.SequentialAgent", SequentialAgent),
],
)
def test_agent_config_discriminator_with_sub_agents(
agent_class_value: str, expected_agent_type: Type[BaseAgent], tmp_path: Path
):
# Create sub-agent config files
sub_agent_dir = tmp_path / "sub_agents"
sub_agent_dir.mkdir()
sub_agent_config = """\
name: sub_agent_{index}
model: gemini-2.5-flash
description: a sub agent
instruction: sub agent instruction
"""
(sub_agent_dir / "sub_agent1.yaml").write_text(
sub_agent_config.format(index=1)
)
(sub_agent_dir / "sub_agent2.yaml").write_text(
sub_agent_config.format(index=2)
)
yaml_content = f"""\
agent_class: {agent_class_value}
name: main_agent
description: main agent with sub agents
sub_agents:
- config_path: sub_agents/sub_agent1.yaml
- config_path: sub_agents/sub_agent2.yaml
"""
config_file = tmp_path / "test_config.yaml"
config_file.write_text(yaml_content)
config = AgentConfig.model_validate(yaml.safe_load(yaml_content))
agent = config_agent_utils.from_config(str(config_file))
assert isinstance(agent, expected_agent_type)
assert config.root.agent_class == agent_class_value
@pytest.mark.parametrize(
("agent_class_value", "expected_agent_type"),
[
("LlmAgent", LlmAgent),
("google.adk.agents.LlmAgent", LlmAgent),
("google.adk.agents.llm_agent.LlmAgent", LlmAgent),
],
)
def test_agent_config_discriminator_llm_agent_with_sub_agents(
agent_class_value: str, expected_agent_type: Type[BaseAgent], tmp_path: Path
):
# Create sub-agent config files
sub_agent_dir = tmp_path / "sub_agents"
sub_agent_dir.mkdir()
sub_agent_config = """\
name: sub_agent_{index}
model: gemini-2.5-flash
description: a sub agent
instruction: sub agent instruction
"""
(sub_agent_dir / "sub_agent1.yaml").write_text(
sub_agent_config.format(index=1)
)
(sub_agent_dir / "sub_agent2.yaml").write_text(
sub_agent_config.format(index=2)
)
yaml_content = f"""\
agent_class: {agent_class_value}
name: main_agent
model: gemini-2.5-flash
description: main agent with sub agents
instruction: main agent instruction
sub_agents:
- config_path: sub_agents/sub_agent1.yaml
- config_path: sub_agents/sub_agent2.yaml
"""
config_file = tmp_path / "test_config.yaml"
config_file.write_text(yaml_content)
config = AgentConfig.model_validate(yaml.safe_load(yaml_content))
agent = config_agent_utils.from_config(str(config_file))
assert isinstance(agent, expected_agent_type)
assert config.root.agent_class == agent_class_value
def test_agent_config_model_code_resolves_preconfigured_client(tmp_path: Path):
"""model_code references a pre-built model instance by fully qualified name.
Configured clients (custom api_base, etc.) must be constructed in Python
and referenced from YAML; YAML cannot pass constructor arguments.
"""
preconfigured = LiteLlm(
model="kimi/k2", api_base="https://proxy.litellm.ai/v1"
)
yaml_content = """\
name: managed_api_agent
description: Agent using LiteLLM managed endpoint
instruction: Respond concisely.
model_code:
name: my_library.clients.my_litellm
"""
config_file = tmp_path / "litellm_agent.yaml"
config_file.write_text(yaml_content)
with mock.patch.object(
config_agent_utils,
"resolve_code_reference",
return_value=preconfigured,
):
agent = config_agent_utils.from_config(str(config_file))
assert isinstance(agent, LlmAgent)
assert agent.model is preconfigured
def test_agent_config_discriminator_custom_agent():
class MyCustomAgentConfig(BaseAgentConfig):
agent_class: Literal["mylib.agents.MyCustomAgent"] = (
"mylib.agents.MyCustomAgent"
)
other_field: str
yaml_content = """\
agent_class: mylib.agents.MyCustomAgent
name: CodePipelineAgent
description: Executes a sequence of code writing, reviewing, and refactoring.
other_field: other value
"""
config_data = yaml.safe_load(yaml_content)
config = AgentConfig.model_validate(config_data)
# pylint: disable=unidiomatic-typecheck Needs exact class matching.
assert type(config.root) is BaseAgentConfig
assert config.root.agent_class == "mylib.agents.MyCustomAgent"
assert config.root.model_extra == {"other_field": "other value"}
my_custom_config = MyCustomAgentConfig.model_validate(
config.root.model_dump()
)
assert my_custom_config.other_field == "other value"
def test_from_config_passes_extra_yaml_fields_to_custom_agent_constructor(
tmp_path: Path,
):
"""Custom agent fields in YAML reach the constructor without a custom config_type.
Mirrors the 1.x AgentConfigMapper behavior: a custom agent subclass with
extra Pydantic fields declared on the agent (not on a config_type) can
populate those fields directly from YAML.
"""
class MyCustomAgent(BaseAgent):
custom_field: str = ""
yaml_content = """\
agent_class: mylib.agents.MyCustomAgent
name: custom_agent
description: a custom agent
custom_field: hello from yaml
"""
config_file = tmp_path / "custom_agent.yaml"
config_file.write_text(yaml_content)
with mock.patch.object(
config_agent_utils,
"resolve_fully_qualified_name",
return_value=MyCustomAgent,
):
agent = config_agent_utils.from_config(str(config_file))
assert isinstance(agent, MyCustomAgent)
assert agent.custom_field == "hello from yaml"
def test_from_config_ignores_extra_yaml_fields_not_on_agent(tmp_path: Path):
"""Extra YAML keys that don't map to constructor params are silently dropped."""
class MyCustomAgent(BaseAgent):
custom_field: str = ""
yaml_content = """\
agent_class: mylib.agents.MyCustomAgent
name: custom_agent
description: a custom agent
custom_field: kept
unknown_field: dropped
"""
config_file = tmp_path / "custom_agent.yaml"
config_file.write_text(yaml_content)
with mock.patch.object(
config_agent_utils,
"resolve_fully_qualified_name",
return_value=MyCustomAgent,
):
agent = config_agent_utils.from_config(str(config_file))
assert agent.custom_field == "kept"
assert not hasattr(agent, "unknown_field")
@pytest.mark.parametrize(
("config_rel_path", "child_rel_path", "child_name", "instruction"),
[
(
Path("main.yaml"),
Path("sub_agents/child.yaml"),
"child_agent",
"I am a child agent",
),
(
Path("level1/level2/nested_main.yaml"),
Path("sub/nested_child.yaml"),
"nested_child",
"I am nested",
),
],
)
def test_resolve_agent_reference_resolves_relative_paths(
config_rel_path: Path,
child_rel_path: Path,
child_name: str,
instruction: str,
tmp_path: Path,
):
"""Verify resolve_agent_reference resolves relative sub-agent paths."""
config_file = tmp_path / config_rel_path
config_file.parent.mkdir(parents=True, exist_ok=True)
child_config_path = config_file.parent / child_rel_path
child_config_path.parent.mkdir(parents=True, exist_ok=True)
child_config_path.write_text(dedent(f"""
agent_class: LlmAgent
name: {child_name}
model: gemini-2.5-flash
instruction: {instruction}
""").lstrip())
config_file.write_text(dedent(f"""
agent_class: LlmAgent
name: main_agent
model: gemini-2.5-flash
instruction: I am the main agent
sub_agents:
- config_path: {child_rel_path.as_posix()}
""").lstrip())
ref_config = AgentRefConfig(config_path=child_rel_path.as_posix())
agent = config_agent_utils.resolve_agent_reference(
ref_config, str(config_file)
)
assert agent.name == child_name
config_dir = os.path.dirname(str(config_file.resolve()))
assert config_dir == str(config_file.parent.resolve())
expected_child_path = os.path.join(config_dir, *child_rel_path.parts)
assert os.path.exists(expected_child_path)
def test_resolve_agent_reference_uses_windows_dirname():
"""Ensure Windows-style config references resolve via os.path.dirname."""
ref_config = AgentRefConfig(config_path="sub\\child.yaml")
recorded: dict[str, str] = {}
def fake_from_config(path: str):
recorded["path"] = path
return "sentinel"
with (
mock.patch.object(
config_agent_utils,
"from_config",
autospec=True,
side_effect=fake_from_config,
),
mock.patch.object(config_agent_utils.os, "path", ntpath),
):
referencing = r"C:\workspace\agents\main.yaml"
result = config_agent_utils.resolve_agent_reference(ref_config, referencing)
expected_path = ntpath.join(
ntpath.dirname(referencing), ref_config.config_path
)
assert result == "sentinel"
assert recorded["path"] == expected_path
def test_resolve_agent_reference_blocks_absolute_path():
"""Verify resolve_agent_reference raises ValueError for absolute paths."""
ref_config = AgentRefConfig(config_path="/etc/passwd")
with pytest.raises(
ValueError,
match="Absolute paths are not allowed in AgentRefConfig config_path",
):
config_agent_utils.resolve_agent_reference(
ref_config, "/workspace/main.yaml"
)
def test_resolve_agent_reference_blocks_path_traversal():
"""Verify resolve_agent_reference raises ValueError for path traversal."""
ref_config = AgentRefConfig(config_path="../outside.yaml")
with pytest.raises(ValueError, match="Path traversal detected"):
config_agent_utils.resolve_agent_reference(
ref_config, "/workspace/agents/main.yaml"
)
# --- Security tests: module blocklist for YAML agent config code references ---
def test_resolve_code_reference_blocks_os_when_enforced():
"""Verify resolve_code_reference blocks os module directly."""
from google.adk.agents.common_configs import CodeConfig
with pytest.raises(ValueError, match="Blocked module reference"):
config_agent_utils.resolve_code_reference(CodeConfig(name="os.system"))
def test_resolve_fully_qualified_name_blocks_subprocess_when_enforced():
"""Verify resolve_fully_qualified_name blocks subprocess module.
resolve_fully_qualified_name wraps all exceptions in
ValueError("Invalid fully qualified name: ..."), so we check the wrapper
and verify the __cause__ carries the blocklist message.
"""
with pytest.raises(
ValueError, match="Invalid fully qualified name"
) as exc_info:
config_agent_utils.resolve_fully_qualified_name("subprocess.Popen")
assert "Blocked module reference" in str(exc_info.value.__cause__)
def test_allowed_module_passes_when_enforced(tmp_path: Path):
"""Verify that google.adk modules are NOT blocked by the module denylist."""
# This should NOT raise — google.adk modules must remain allowed
result = config_agent_utils.resolve_fully_qualified_name(
"google.adk.agents.llm_agent.LlmAgent"
)
assert result is LlmAgent
@pytest.mark.parametrize(
"blocked_module",
[
"os.system",
"subprocess.call",
"builtins.exec",
],
)
def test_resolve_agent_code_reference_blocks_when_enforced(
blocked_module: str,
):
"""Verify _resolve_agent_code_reference blocks dangerous modules."""
with pytest.raises(ValueError, match="Blocked module reference"):
config_agent_utils._resolve_agent_code_reference(blocked_module)
@pytest.mark.parametrize(
"blocked_ref",
[
"os.system",
"subprocess.call",
"builtins.exec",
"pickle.loads",
],
)
def test_resolve_tools_blocks_dangerous_modules(blocked_ref: str):
"""Verify _resolve_tools blocks dangerous modules for user-defined tools."""
from google.adk.agents.llm_agent import LlmAgent
from google.adk.tools.tool_configs import ToolConfig
tool_config = ToolConfig(name=blocked_ref)
with pytest.raises(ValueError, match="Blocked module reference"):
LlmAgent._resolve_tools([tool_config], "/fake/path.yaml")
def test_resolve_tools_allows_builtin_adk_tools():
"""Verify _resolve_tools allows ADK built-in tools (no dot in name)."""
from google.adk.agents.llm_agent import LlmAgent
from google.adk.tools.tool_configs import ToolConfig
# Built-in tools have no dot — they import from google.adk.tools
tool_config = ToolConfig(name="google_search")
# Should NOT raise — this is a safe, hardcoded import path
resolved = LlmAgent._resolve_tools([tool_config], "/fake/path.yaml")
assert len(resolved) == 1
@pytest.mark.parametrize(
"blocked_ref",
[
"ftplib.FTP",
"smtplib.SMTP",
"xmlrpc.client",
"telnetlib.Telnet",
"poplib.POP3",
"imaplib.IMAP4",
"asyncio.run",
"pathlib.Path",
],
)
def test_newly_blocked_network_modules_are_rejected(blocked_ref: str):
"""Verify newly added network-capable modules are blocked.
resolve_fully_qualified_name wraps errors, so we check the cause.
"""
with pytest.raises(
ValueError, match="Invalid fully qualified name"
) as exc_info:
config_agent_utils.resolve_fully_qualified_name(blocked_ref)
assert "Blocked module reference" in str(exc_info.value.__cause__)
def test_denylist_can_be_disabled():
"""Verify _set_enforce_denylist(False) disables module blocking."""
config_agent_utils._set_enforce_denylist(False)
try:
# os.getcwd is a real, importable reference — should succeed
result = config_agent_utils.resolve_fully_qualified_name("os.getcwd")
assert callable(result)
finally:
config_agent_utils._set_enforce_denylist(True)
def test_load_config_from_path_blocks_args_when_enforced(tmp_path: Path):
"""_load_config_from_path blocks the 'args' key when enforcement is on."""
config_file = tmp_path / "agent.yaml"
config_file.write_text("name: my_agent\nargs:\n key: value\n")
config_agent_utils._set_enforce_yaml_key_denylist(True)
try:
with pytest.raises(ValueError) as exc_info:
config_agent_utils._load_config_from_path(str(config_file))
assert "Blocked key 'args' found" in str(exc_info.value)
finally:
config_agent_utils._set_enforce_yaml_key_denylist(False)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,509 @@
# 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 the CallbackContext class."""
from unittest.mock import AsyncMock
from unittest.mock import MagicMock
from unittest.mock import Mock
from google.adk.agents.callback_context import CallbackContext
from google.adk.auth.auth_credential import AuthCredential
from google.adk.auth.auth_credential import AuthCredentialTypes
from google.adk.auth.auth_tool import AuthConfig
from google.adk.memory.memory_entry import MemoryEntry
from google.adk.tools.tool_context import ToolContext
from google.genai import types
from google.genai.types import Part
import pytest
@pytest.fixture
def mock_invocation_context():
"""Create a mock invocation context for testing."""
mock_context = MagicMock()
mock_context.invocation_id = "test-invocation-id"
mock_context.agent.name = "test-agent-name"
mock_context.session.state = {"key1": "value1", "key2": "value2"}
mock_context.session.id = "test-session-id"
mock_context.app_name = "test-app"
mock_context.user_id = "test-user"
mock_context.artifact_service = None
mock_context.credential_service = None
return mock_context
@pytest.fixture
def mock_artifact_service():
"""Create a mock artifact service for testing."""
mock_service = AsyncMock()
mock_service.list_artifact_keys.return_value = [
"file1.txt",
"file2.txt",
"file3.txt",
]
return mock_service
@pytest.fixture
def callback_context_with_artifact_service(
mock_invocation_context, mock_artifact_service
):
"""Create a CallbackContext with a mock artifact service."""
mock_invocation_context.artifact_service = mock_artifact_service
return CallbackContext(mock_invocation_context)
@pytest.fixture
def callback_context_without_artifact_service(mock_invocation_context):
"""Create a CallbackContext without an artifact service."""
mock_invocation_context.artifact_service = None
return CallbackContext(mock_invocation_context)
@pytest.fixture
def mock_auth_config():
"""Create a mock auth config for testing."""
mock_config = Mock(spec=AuthConfig)
return mock_config
@pytest.fixture
def mock_auth_credential():
"""Create a mock auth credential for testing."""
mock_credential = Mock(spec=AuthCredential)
mock_credential.auth_type = AuthCredentialTypes.OAUTH2
return mock_credential
class TestCallbackContextListArtifacts:
"""Test the list_artifacts method in CallbackContext."""
@pytest.mark.asyncio
async def test_list_artifacts_returns_artifact_keys(
self, callback_context_with_artifact_service, mock_artifact_service
):
"""Test that list_artifacts returns the artifact keys from the service."""
result = await callback_context_with_artifact_service.list_artifacts()
assert result == ["file1.txt", "file2.txt", "file3.txt"]
mock_artifact_service.list_artifact_keys.assert_called_once_with(
app_name="test-app",
user_id="test-user",
session_id="test-session-id",
)
@pytest.mark.asyncio
async def test_list_artifacts_returns_empty_list(
self, callback_context_with_artifact_service, mock_artifact_service
):
"""Test that list_artifacts returns an empty list when no artifacts exist."""
mock_artifact_service.list_artifact_keys.return_value = []
result = await callback_context_with_artifact_service.list_artifacts()
assert result == []
mock_artifact_service.list_artifact_keys.assert_called_once_with(
app_name="test-app",
user_id="test-user",
session_id="test-session-id",
)
@pytest.mark.asyncio
async def test_list_artifacts_raises_value_error_when_service_is_none(
self, callback_context_without_artifact_service
):
"""Test that list_artifacts raises ValueError when artifact service is None."""
with pytest.raises(
ValueError, match="Artifact service is not initialized."
):
await callback_context_without_artifact_service.list_artifacts()
@pytest.mark.asyncio
async def test_list_artifacts_passes_through_service_exceptions(
self, callback_context_with_artifact_service, mock_artifact_service
):
"""Test that list_artifacts passes through exceptions from the artifact service."""
mock_artifact_service.list_artifact_keys.side_effect = Exception(
"Service error"
)
with pytest.raises(Exception, match="Service error"):
await callback_context_with_artifact_service.list_artifacts()
class TestCallbackContext:
"""Test suite for CallbackContext."""
@pytest.mark.asyncio
async def test_tool_context_inherits_list_artifacts(
self, mock_invocation_context, mock_artifact_service
):
"""Test that ToolContext inherits the list_artifacts method from CallbackContext."""
mock_invocation_context.artifact_service = mock_artifact_service
tool_context = ToolContext(mock_invocation_context)
result = await tool_context.list_artifacts()
assert result == ["file1.txt", "file2.txt", "file3.txt"]
mock_artifact_service.list_artifact_keys.assert_called_once_with(
app_name="test-app",
user_id="test-user",
session_id="test-session-id",
)
@pytest.mark.asyncio
async def test_tool_context_list_artifacts_raises_value_error_when_service_is_none(
self, mock_invocation_context
):
"""Test that ToolContext's list_artifacts raises ValueError when artifact service is None."""
mock_invocation_context.artifact_service = None
tool_context = ToolContext(mock_invocation_context)
with pytest.raises(
ValueError, match="Artifact service is not initialized."
):
await tool_context.list_artifacts()
def test_tool_context_has_list_artifacts_method(self):
"""Test that ToolContext has the list_artifacts method available."""
assert hasattr(ToolContext, "list_artifacts")
assert callable(getattr(ToolContext, "list_artifacts"))
def test_callback_context_has_list_artifacts_method(self):
"""Test that CallbackContext has the list_artifacts method available."""
assert hasattr(CallbackContext, "list_artifacts")
assert callable(getattr(CallbackContext, "list_artifacts"))
def test_tool_context_shares_same_list_artifacts_method_with_callback_context(
self,
):
"""Test that ToolContext and CallbackContext share the same list_artifacts method."""
assert ToolContext.list_artifacts is CallbackContext.list_artifacts
def test_initialization(self, mock_invocation_context):
"""Test CallbackContext initialization."""
context = CallbackContext(mock_invocation_context)
assert context._invocation_context == mock_invocation_context
assert context._event_actions is not None
assert context._state is not None
@pytest.mark.asyncio
async def test_save_credential_with_service(
self, mock_invocation_context, mock_auth_config
):
"""Test save_credential when credential service is available."""
# Mock credential service
credential_service = AsyncMock()
mock_invocation_context.credential_service = credential_service
context = CallbackContext(mock_invocation_context)
await context.save_credential(mock_auth_config)
credential_service.save_credential.assert_called_once_with(
mock_auth_config, context
)
@pytest.mark.asyncio
async def test_save_credential_no_service(
self, mock_invocation_context, mock_auth_config
):
"""Test save_credential when credential service is not available."""
mock_invocation_context.credential_service = None
context = CallbackContext(mock_invocation_context)
with pytest.raises(
ValueError, match="Credential service is not initialized"
):
await context.save_credential(mock_auth_config)
@pytest.mark.asyncio
async def test_load_credential_with_service(
self, mock_invocation_context, mock_auth_config, mock_auth_credential
):
"""Test load_credential when credential service is available."""
# Mock credential service
credential_service = AsyncMock()
credential_service.load_credential.return_value = mock_auth_credential
mock_invocation_context.credential_service = credential_service
context = CallbackContext(mock_invocation_context)
result = await context.load_credential(mock_auth_config)
credential_service.load_credential.assert_called_once_with(
mock_auth_config, context
)
assert result == mock_auth_credential
@pytest.mark.asyncio
async def test_load_credential_no_service(
self, mock_invocation_context, mock_auth_config
):
"""Test load_credential when credential service is not available."""
mock_invocation_context.credential_service = None
context = CallbackContext(mock_invocation_context)
with pytest.raises(
ValueError, match="Credential service is not initialized"
):
await context.load_credential(mock_auth_config)
@pytest.mark.asyncio
async def test_load_credential_returns_none(
self, mock_invocation_context, mock_auth_config
):
"""Test load_credential returns None when credential not found."""
# Mock credential service
credential_service = AsyncMock()
credential_service.load_credential.return_value = None
mock_invocation_context.credential_service = credential_service
context = CallbackContext(mock_invocation_context)
result = await context.load_credential(mock_auth_config)
credential_service.load_credential.assert_called_once_with(
mock_auth_config, context
)
assert result is None
@pytest.mark.asyncio
async def test_save_artifact_integration(self, mock_invocation_context):
"""Test save_artifact to ensure credential methods follow same pattern."""
# Mock artifact service
artifact_service = AsyncMock()
artifact_service.save_artifact.return_value = 1
mock_invocation_context.artifact_service = artifact_service
context = CallbackContext(mock_invocation_context)
test_artifact = Part.from_text(text="test content")
version = await context.save_artifact("test_file.txt", test_artifact)
artifact_service.save_artifact.assert_called_once_with(
app_name="test-app",
user_id="test-user",
session_id="test-session-id",
filename="test_file.txt",
artifact=test_artifact,
custom_metadata=None,
)
assert version == 1
@pytest.mark.asyncio
async def test_load_artifact_integration(self, mock_invocation_context):
"""Test load_artifact to ensure credential methods follow same pattern."""
# Mock artifact service
artifact_service = AsyncMock()
test_artifact = Part.from_text(text="test content")
artifact_service.load_artifact.return_value = test_artifact
mock_invocation_context.artifact_service = artifact_service
context = CallbackContext(mock_invocation_context)
result = await context.load_artifact("test_file.txt")
artifact_service.load_artifact.assert_called_once_with(
app_name="test-app",
user_id="test-user",
session_id="test-session-id",
filename="test_file.txt",
version=None,
)
assert result == test_artifact
class TestCallbackContextAddSessionToMemory:
"""Test the add_session_to_memory method in CallbackContext."""
@pytest.mark.asyncio
async def test_add_session_to_memory_success(self, mock_invocation_context):
"""Test that add_session_to_memory calls the memory service correctly."""
memory_service = AsyncMock()
mock_invocation_context.memory_service = memory_service
context = CallbackContext(mock_invocation_context)
await context.add_session_to_memory()
memory_service.add_session_to_memory.assert_called_once_with(
mock_invocation_context.session
)
@pytest.mark.asyncio
async def test_add_session_to_memory_no_service_raises(
self, mock_invocation_context
):
"""Test that add_session_to_memory raises ValueError when memory service is None."""
mock_invocation_context.memory_service = None
context = CallbackContext(mock_invocation_context)
with pytest.raises(
ValueError,
match=(
r"Cannot add session to memory: memory service is not available\."
),
):
await context.add_session_to_memory()
@pytest.mark.asyncio
async def test_add_session_to_memory_passes_through_service_exceptions(
self, mock_invocation_context
):
"""Test that add_session_to_memory passes through exceptions from the memory service."""
memory_service = AsyncMock()
memory_service.add_session_to_memory.side_effect = Exception(
"Memory service error"
)
mock_invocation_context.memory_service = memory_service
context = CallbackContext(mock_invocation_context)
with pytest.raises(Exception, match="Memory service error"):
await context.add_session_to_memory()
class TestCallbackContextAddEventsToMemory:
"""Tests add_events_to_memory in CallbackContext."""
@pytest.mark.asyncio
async def test_add_events_to_memory_success(self, mock_invocation_context):
"""Tests that add_events_to_memory calls the memory service correctly."""
memory_service = AsyncMock()
mock_invocation_context.memory_service = memory_service
test_event = MagicMock()
context = CallbackContext(mock_invocation_context)
await context.add_events_to_memory(
events=[test_event],
custom_metadata={"ttl": "6000s"},
)
memory_service.add_events_to_memory.assert_called_once_with(
app_name=mock_invocation_context.session.app_name,
user_id=mock_invocation_context.session.user_id,
session_id=mock_invocation_context.session.id,
events=[test_event],
custom_metadata={"ttl": "6000s"},
)
@pytest.mark.asyncio
async def test_add_events_to_memory_no_service_raises(
self, mock_invocation_context
):
"""Tests that add_events_to_memory raises ValueError with no service."""
mock_invocation_context.memory_service = None
context = CallbackContext(mock_invocation_context)
with pytest.raises(
ValueError,
match=r"Cannot add events to memory: memory service is not available\.",
):
await context.add_events_to_memory(events=[MagicMock()])
@pytest.mark.asyncio
async def test_add_memory_forwards_metadata(self, mock_invocation_context):
"""Tests that add_memory forwards memories and metadata."""
memory_service = AsyncMock()
mock_invocation_context.memory_service = memory_service
memories = [
MemoryEntry(content=types.Content(parts=[types.Part(text="fact one")]))
]
metadata = {"ttl": "6000s"}
context = CallbackContext(mock_invocation_context)
await context.add_memory(memories=memories, custom_metadata=metadata)
memory_service.add_memory.assert_called_once_with(
app_name=mock_invocation_context.session.app_name,
user_id=mock_invocation_context.session.user_id,
memories=memories,
custom_metadata=metadata,
)
@pytest.mark.asyncio
async def test_add_memory_accepts_memory_entries(
self, mock_invocation_context
):
"""Tests that add_memory forwards MemoryEntry inputs unchanged."""
memory_service = AsyncMock()
mock_invocation_context.memory_service = memory_service
memory_entry = MemoryEntry(
content=types.Content(parts=[types.Part(text="fact one")])
)
context = CallbackContext(mock_invocation_context)
await context.add_memory(memories=[memory_entry])
memory_service.add_memory.assert_called_once_with(
app_name=mock_invocation_context.session.app_name,
user_id=mock_invocation_context.session.user_id,
memories=[memory_entry],
custom_metadata=None,
)
@pytest.mark.asyncio
async def test_add_memory_no_service_raises(self, mock_invocation_context):
"""Tests that add_memory raises ValueError with no service."""
mock_invocation_context.memory_service = None
context = CallbackContext(mock_invocation_context)
with pytest.raises(
ValueError,
match=r"Cannot add memory: memory service is not available\.",
):
await context.add_memory(
memories=[
MemoryEntry(
content=types.Content(parts=[types.Part(text="fact one")])
)
]
)
class TestToolContextAddSessionToMemory:
"""Test the add_session_to_memory method in ToolContext."""
@pytest.mark.asyncio
async def test_add_session_to_memory_success(self, mock_invocation_context):
"""Test that ToolContext.add_session_to_memory calls the memory service correctly."""
memory_service = AsyncMock()
mock_invocation_context.memory_service = memory_service
tool_context = ToolContext(mock_invocation_context)
await tool_context.add_session_to_memory()
memory_service.add_session_to_memory.assert_called_once_with(
mock_invocation_context.session
)
@pytest.mark.asyncio
async def test_add_session_to_memory_no_service_raises(
self, mock_invocation_context
):
"""Test that ToolContext.add_session_to_memory raises ValueError when memory service is None."""
mock_invocation_context.memory_service = None
tool_context = ToolContext(mock_invocation_context)
with pytest.raises(
ValueError,
match=(
r"Cannot add session to memory: memory service is not available\."
),
):
await tool_context.add_session_to_memory()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,174 @@
# 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 ContextCacheConfig."""
from google.adk.agents.context_cache_config import ContextCacheConfig
from pydantic import ValidationError
import pytest
class TestContextCacheConfig:
"""Test suite for ContextCacheConfig."""
def test_default_values(self):
"""Test that default values are set correctly."""
config = ContextCacheConfig()
assert config.cache_intervals == 10
assert config.ttl_seconds == 1800 # 30 minutes
assert config.min_tokens == 0
def test_custom_values(self):
"""Test creating config with custom values."""
config = ContextCacheConfig(
cache_intervals=15, ttl_seconds=3600, min_tokens=1024
)
assert config.cache_intervals == 15
assert config.ttl_seconds == 3600
assert config.min_tokens == 1024
def test_cache_intervals_validation(self):
"""Test cache_intervals validation constraints."""
# Valid range
config = ContextCacheConfig(cache_intervals=1)
assert config.cache_intervals == 1
config = ContextCacheConfig(cache_intervals=100)
assert config.cache_intervals == 100
# Invalid: too low
with pytest.raises(ValidationError) as exc_info:
ContextCacheConfig(cache_intervals=0)
assert "greater than or equal to 1" in str(exc_info.value)
# Invalid: too high
with pytest.raises(ValidationError) as exc_info:
ContextCacheConfig(cache_intervals=101)
assert "less than or equal to 100" in str(exc_info.value)
def test_ttl_seconds_validation(self):
"""Test ttl_seconds validation constraints."""
# Valid range
config = ContextCacheConfig(ttl_seconds=1)
assert config.ttl_seconds == 1
config = ContextCacheConfig(ttl_seconds=86400) # 24 hours
assert config.ttl_seconds == 86400
# Invalid: zero or negative
with pytest.raises(ValidationError) as exc_info:
ContextCacheConfig(ttl_seconds=0)
assert "greater than 0" in str(exc_info.value)
with pytest.raises(ValidationError) as exc_info:
ContextCacheConfig(ttl_seconds=-1)
assert "greater than 0" in str(exc_info.value)
def test_min_tokens_validation(self):
"""Test min_tokens validation constraints."""
# Valid values
config = ContextCacheConfig(min_tokens=0)
assert config.min_tokens == 0
config = ContextCacheConfig(min_tokens=1024)
assert config.min_tokens == 1024
# Invalid: negative
with pytest.raises(ValidationError) as exc_info:
ContextCacheConfig(min_tokens=-1)
assert "greater than or equal to 0" in str(exc_info.value)
def test_ttl_string_property(self):
"""Test ttl_string property returns correct format."""
config = ContextCacheConfig(ttl_seconds=1800)
assert config.ttl_string == "1800s"
config = ContextCacheConfig(ttl_seconds=3600)
assert config.ttl_string == "3600s"
def test_str_representation(self):
"""Test string representation for logging."""
config = ContextCacheConfig(
cache_intervals=15, ttl_seconds=3600, min_tokens=1024
)
expected = (
"ContextCacheConfig(cache_intervals=15, ttl=3600s, min_tokens=1024, "
"create_http_options=None)"
)
assert str(config) == expected
def test_str_representation_defaults(self):
"""Test string representation with default values."""
config = ContextCacheConfig()
expected = (
"ContextCacheConfig(cache_intervals=10, ttl=1800s, min_tokens=0, "
"create_http_options=None)"
)
assert str(config) == expected
def test_pydantic_model_validation(self):
"""Test that Pydantic model validation works correctly."""
# Test extra fields are forbidden
with pytest.raises(ValidationError) as exc_info:
ContextCacheConfig(cache_intervals=10, extra_field="not_allowed")
assert "extra" in str(exc_info.value).lower()
def test_field_descriptions(self):
"""Test that fields have proper descriptions."""
fields = ContextCacheConfig.model_fields
assert "cache_intervals" in fields
assert (
"Maximum number of invocations" in fields["cache_intervals"].description
)
assert "ttl_seconds" in fields
assert "Time-to-live for cache" in fields["ttl_seconds"].description
assert "min_tokens" in fields
assert "Minimum prior-request tokens" in fields["min_tokens"].description
def test_immutability_config(self):
"""Test that the model config is set correctly."""
config = ContextCacheConfig()
assert config.model_config["extra"] == "forbid"
def test_realistic_scenarios(self):
"""Test realistic configuration scenarios."""
# Quick caching for development
dev_config = ContextCacheConfig(
cache_intervals=5, ttl_seconds=600, min_tokens=0 # 10 minutes
)
assert dev_config.cache_intervals == 5
assert dev_config.ttl_seconds == 600
# Production caching
prod_config = ContextCacheConfig(
cache_intervals=20, ttl_seconds=7200, min_tokens=2048 # 2 hours
)
assert prod_config.cache_intervals == 20
assert prod_config.ttl_seconds == 7200
assert prod_config.min_tokens == 2048
# Conservative caching
conservative_config = ContextCacheConfig(
cache_intervals=3, ttl_seconds=300, min_tokens=4096 # 5 minutes
)
assert conservative_config.cache_intervals == 3
assert conservative_config.ttl_seconds == 300
assert conservative_config.min_tokens == 4096
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,694 @@
# 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 unittest.mock import Mock
from google.adk.agents.base_agent import BaseAgent
from google.adk.agents.base_agent import BaseAgentState
from google.adk.agents.invocation_context import InvocationContext
from google.adk.apps import ResumabilityConfig
from google.adk.events.event import Event
from google.adk.events.event_actions import EventActions
from google.adk.sessions.base_session_service import BaseSessionService
from google.adk.sessions.session import Session
from google.genai.types import Content
from google.genai.types import FunctionCall
from google.genai.types import Part
import pytest
from .. import testing_utils
class TestInvocationContext:
"""Test suite for InvocationContext."""
@pytest.fixture
def mock_events(self):
"""Create mock events for testing."""
event1 = Mock(spec=Event)
event1.invocation_id = 'inv_1'
event1.branch = 'agent_1'
event2 = Mock(spec=Event)
event2.invocation_id = 'inv_1'
event2.branch = 'agent_2'
event3 = Mock(spec=Event)
event3.invocation_id = 'inv_2'
event3.branch = 'agent_1'
event4 = Mock(spec=Event)
event4.invocation_id = 'inv_2'
event4.branch = 'agent_2'
return [event1, event2, event3, event4]
@pytest.fixture
def mock_invocation_context(self, mock_events):
"""Create a mock invocation context for testing."""
ctx = InvocationContext(
session_service=Mock(spec=BaseSessionService),
agent=Mock(spec=BaseAgent),
invocation_id='inv_1',
branch='agent_1',
session=Mock(spec=Session, events=mock_events),
)
return ctx
def test_get_events_returns_all_events_by_default(
self, mock_invocation_context, mock_events
):
"""Tests that get_events returns all events when no filters are applied."""
events = mock_invocation_context._get_events()
assert events == mock_events
def test_get_events_filters_by_current_invocation(
self, mock_invocation_context, mock_events
):
"""Tests that get_events correctly filters by the current invocation."""
event1, event2, _, _ = mock_events
events = mock_invocation_context._get_events(current_invocation=True)
assert events == [event1, event2]
def test_get_events_filters_by_current_branch(
self, mock_invocation_context, mock_events
):
"""Tests that get_events correctly filters by the current branch."""
event1, _, event3, _ = mock_events
events = mock_invocation_context._get_events(current_branch=True)
assert events == [event1, event3]
def test_get_events_filters_by_invocation_and_branch(
self, mock_invocation_context, mock_events
):
"""Tests that get_events filters by invocation and branch."""
event1, _, _, _ = mock_events
events = mock_invocation_context._get_events(
current_invocation=True,
current_branch=True,
)
assert events == [event1]
def test_get_events_with_no_events_in_session(self, mock_invocation_context):
"""Tests get_events when the session has no events."""
mock_invocation_context.session.events = []
events = mock_invocation_context._get_events()
assert not events
def test_get_events_with_no_matching_events(self, mock_invocation_context):
"""Tests get_events when no events match the filters."""
mock_invocation_context.invocation_id = 'inv_3'
mock_invocation_context.branch = 'branch_C'
# Filter by invocation
events = mock_invocation_context._get_events(current_invocation=True)
assert not events
# Filter by branch
events = mock_invocation_context._get_events(current_branch=True)
assert not events
# Filter by both
events = mock_invocation_context._get_events(
current_invocation=True,
current_branch=True,
)
assert not events
class TestInvocationContextWithAppResumablity:
"""Test suite for InvocationContext regarding app resumability."""
@pytest.fixture
def long_running_function_call(self) -> FunctionCall:
"""A long running function call."""
return FunctionCall(
id='tool_call_id_1',
name='long_running_function_call',
args={},
)
@pytest.fixture
def event_to_pause(self, long_running_function_call) -> Event:
"""An event with a long running function call."""
return Event(
invocation_id='inv_1',
author='agent',
content=testing_utils.ModelContent(
[Part(function_call=long_running_function_call)]
),
long_running_tool_ids=[long_running_function_call.id],
)
def _create_test_invocation_context(
self, resumability_config: ResumabilityConfig | None = None
) -> InvocationContext:
"""Create a mock invocation context for testing."""
ctx = InvocationContext(
session_service=Mock(spec=BaseSessionService),
agent=Mock(spec=BaseAgent),
invocation_id='inv_1',
session=Mock(spec=Session, events=[]),
resumability_config=resumability_config,
)
return ctx
def test_should_pause_invocation_with_resumable_app(self, event_to_pause):
"""Tests should_pause_invocation with a resumable app."""
mock_invocation_context = self._create_test_invocation_context(
ResumabilityConfig(is_resumable=True)
)
assert mock_invocation_context.should_pause_invocation(event_to_pause)
def test_should_pause_invocation_with_non_resumable_app(self, event_to_pause):
"""Tests should_pause_invocation pauses even without resumability."""
invocation_context = self._create_test_invocation_context(
ResumabilityConfig(is_resumable=False)
)
assert invocation_context.should_pause_invocation(event_to_pause)
def test_should_not_pause_invocation_with_no_long_running_tool_ids(
self, event_to_pause
):
"""Tests should_pause_invocation with no long running tools."""
invocation_context = self._create_test_invocation_context(
ResumabilityConfig(is_resumable=True)
)
nonpausable_event = event_to_pause.model_copy(
update={'long_running_tool_ids': []}
)
assert not invocation_context.should_pause_invocation(nonpausable_event)
def test_should_not_pause_invocation_with_no_function_calls(
self, event_to_pause
):
"""Tests should_pause_invocation with a non-model event."""
mock_invocation_context = self._create_test_invocation_context(
ResumabilityConfig(is_resumable=True)
)
nonpausable_event = event_to_pause.model_copy(
update={'content': testing_utils.UserContent('test text part')}
)
assert not mock_invocation_context.should_pause_invocation(
nonpausable_event
)
def test_should_not_pause_when_user_resumes_in_sub_branch(
self, event_to_pause, long_running_function_call
):
"""We do not pause the invocation if a subsequent user event belongs to a sub-branch."""
# Arrange
mock_invocation_context = self._create_test_invocation_context()
user_event = Event(
invocation_id='inv_1',
author='user',
branch=f'agent@{long_running_function_call.id}.child',
)
mock_invocation_context.session.events = [event_to_pause, user_event]
# Act
should_pause = mock_invocation_context.should_pause_invocation(
event_to_pause
)
# Assert
assert not should_pause
def test_should_not_pause_when_user_resumes_in_deeply_nested_sub_branch(
self, event_to_pause, long_running_function_call
):
"""We do not pause if the user resumes in a deeply nested sub-branch containing the tool call."""
# Arrange
mock_invocation_context = self._create_test_invocation_context()
user_event = Event(
invocation_id='inv_1',
author='user',
branch=f'parent@other.child@{long_running_function_call.id}.grandchild',
)
mock_invocation_context.session.events = [event_to_pause, user_event]
# Act
should_pause = mock_invocation_context.should_pause_invocation(
event_to_pause
)
# Assert
assert not should_pause
def test_should_pause_when_user_resumes_in_different_branch(
self, event_to_pause
):
"""We still pause the invocation if the subsequent user event belongs to a different branch."""
# Arrange
mock_invocation_context = self._create_test_invocation_context()
user_event = Event(
invocation_id='inv_1',
author='user',
branch='parent@different_id.child',
)
mock_invocation_context.session.events = [event_to_pause, user_event]
# Act
should_pause = mock_invocation_context.should_pause_invocation(
event_to_pause
)
# Assert
assert should_pause
def test_is_resumable_true(self):
"""Tests that is_resumable is True when resumability is enabled."""
invocation_context = self._create_test_invocation_context(
ResumabilityConfig(is_resumable=True)
)
assert invocation_context.is_resumable
def test_is_resumable_false(self):
"""Tests that is_resumable is False when resumability is disabled."""
invocation_context = self._create_test_invocation_context(
ResumabilityConfig(is_resumable=False)
)
assert not invocation_context.is_resumable
def test_is_resumable_no_config(self):
"""Tests that is_resumable is False when no resumability config is set."""
invocation_context = self._create_test_invocation_context(None)
assert not invocation_context.is_resumable
def test_populate_invocation_agent_states_not_resumable(self):
"""Tests that populate_invocation_agent_states does nothing if not resumable."""
invocation_context = self._create_test_invocation_context(
ResumabilityConfig(is_resumable=False)
)
event = Event(
invocation_id='inv_1',
author='agent1',
actions=EventActions(end_of_agent=True, agent_state=None),
)
invocation_context.session.events = [event]
invocation_context.populate_invocation_agent_states()
assert not invocation_context.agent_states
assert not invocation_context.end_of_agents
def test_populate_invocation_agent_states_end_of_agent(self):
"""Tests that populate_invocation_agent_states handles end_of_agent."""
invocation_context = self._create_test_invocation_context(
ResumabilityConfig(is_resumable=True)
)
event = Event(
invocation_id='inv_1',
author='agent1',
actions=EventActions(end_of_agent=True, agent_state=None),
)
invocation_context.session.events = [event]
invocation_context.populate_invocation_agent_states()
assert not invocation_context.agent_states
assert invocation_context.end_of_agents == {'agent1': True}
def test_populate_invocation_agent_states_with_agent_state(self):
"""Tests that populate_invocation_agent_states handles agent_state."""
invocation_context = self._create_test_invocation_context(
ResumabilityConfig(is_resumable=True)
)
event = Event(
invocation_id='inv_1',
author='agent1',
actions=EventActions(
end_of_agent=False,
agent_state=BaseAgentState().model_dump(mode='json'),
),
)
invocation_context.session.events = [event]
invocation_context.populate_invocation_agent_states()
assert invocation_context.agent_states == {'agent1': {}}
assert invocation_context.end_of_agents == {'agent1': False}
def test_populate_invocation_agent_states_with_agent_state_and_end_of_agent(
self,
):
"""Tests that populate_invocation_agent_states handles agent_state and end_of_agent."""
invocation_context = self._create_test_invocation_context(
ResumabilityConfig(is_resumable=True)
)
event = Event(
invocation_id='inv_1',
author='agent1',
actions=EventActions(
end_of_agent=True,
agent_state=BaseAgentState().model_dump(mode='json'),
),
)
invocation_context.session.events = [event]
invocation_context.populate_invocation_agent_states()
# When both agent_state and end_of_agent are set, agent_state should be
# cleared, as end_of_agent is of a higher priority.
assert not invocation_context.agent_states
assert invocation_context.end_of_agents == {'agent1': True}
def test_populate_invocation_agent_states_with_content_no_state(self):
"""Tests that populate_invocation_agent_states creates default state."""
invocation_context = self._create_test_invocation_context(
ResumabilityConfig(is_resumable=True)
)
event = Event(
invocation_id='inv_1',
author='agent1',
actions=EventActions(end_of_agent=False, agent_state=None),
content=Content(role='model', parts=[Part(text='hi')]),
)
invocation_context.session.events = [event]
invocation_context.populate_invocation_agent_states()
assert invocation_context.agent_states == {
'agent1': BaseAgentState().model_dump(mode='json')
}
assert invocation_context.end_of_agents == {'agent1': False}
def test_populate_invocation_agent_states_user_message_event(self):
"""Tests that populate_invocation_agent_states ignores user message events for default state."""
invocation_context = self._create_test_invocation_context(
ResumabilityConfig(is_resumable=True)
)
event = Event(
invocation_id='inv_1',
author='user',
actions=EventActions(end_of_agent=False, agent_state=None),
content=Content(role='user', parts=[Part(text='hi')]),
)
invocation_context.session.events = [event]
invocation_context.populate_invocation_agent_states()
assert not invocation_context.agent_states
assert not invocation_context.end_of_agents
def test_populate_invocation_agent_states_no_content(self):
"""Tests that populate_invocation_agent_states ignores events with no content if no state."""
invocation_context = self._create_test_invocation_context(
ResumabilityConfig(is_resumable=True)
)
event = Event(
invocation_id='inv_1',
author='agent1',
actions=EventActions(end_of_agent=None, agent_state=None),
content=None,
)
invocation_context.session.events = [event]
invocation_context.populate_invocation_agent_states()
assert not invocation_context.agent_states
assert not invocation_context.end_of_agents
def test_set_agent_state_with_end_of_agent_true(self):
"""Tests that set_agent_state clears agent_state and sets end_of_agent to True."""
invocation_context = self._create_test_invocation_context(
ResumabilityConfig(is_resumable=True)
)
invocation_context.agent_states['agent1'] = {}
invocation_context.end_of_agents['agent1'] = False
# Set state with end_of_agent=True, which should clear the existing
# agent_state.
invocation_context.set_agent_state('agent1', end_of_agent=True)
assert 'agent1' not in invocation_context.agent_states
assert invocation_context.end_of_agents['agent1']
def test_set_agent_state_with_agent_state(self):
"""Tests that set_agent_state sets agent_state and sets end_of_agent to False."""
agent_state = BaseAgentState()
invocation_context = self._create_test_invocation_context(
ResumabilityConfig(is_resumable=True)
)
invocation_context.end_of_agents['agent1'] = True
# Set state with agent_state=agent_state, which should set the agent_state
# and reset the end_of_agent flag to False.
invocation_context.set_agent_state('agent1', agent_state=agent_state)
assert invocation_context.agent_states['agent1'] == agent_state.model_dump(
mode='json'
)
assert invocation_context.end_of_agents['agent1'] is False
def test_reset_agent_state(self):
"""Tests that set_agent_state clears agent_state and end_of_agent."""
invocation_context = self._create_test_invocation_context(
ResumabilityConfig(is_resumable=True)
)
invocation_context.agent_states['agent1'] = {}
invocation_context.end_of_agents['agent1'] = True
# Reset state, which should clear the agent_state and end_of_agent flag.
invocation_context.set_agent_state('agent1')
assert 'agent1' not in invocation_context.agent_states
assert 'agent1' not in invocation_context.end_of_agents
def test_reset_sub_agent_states(self):
"""Tests that reset_sub_agent_states resets sub-agent states."""
sub_sub_agent_1 = BaseAgent(name='sub_sub_agent_1')
sub_agent_1 = BaseAgent(name='sub_agent_1', sub_agents=[sub_sub_agent_1])
sub_agent_2 = BaseAgent(name='sub_agent_2')
root_agent = BaseAgent(
name='root_agent', sub_agents=[sub_agent_1, sub_agent_2]
)
invocation_context = self._create_test_invocation_context(
ResumabilityConfig(is_resumable=True)
)
invocation_context.agent = root_agent
invocation_context.set_agent_state(
'sub_agent_1', agent_state=BaseAgentState()
)
invocation_context.set_agent_state('sub_agent_2', end_of_agent=True)
invocation_context.set_agent_state(
'sub_sub_agent_1', agent_state=BaseAgentState()
)
assert 'sub_agent_1' in invocation_context.agent_states
assert 'sub_agent_2' in invocation_context.end_of_agents
assert 'sub_sub_agent_1' in invocation_context.agent_states
invocation_context.reset_sub_agent_states('root_agent')
assert 'sub_agent_1' not in invocation_context.agent_states
assert 'sub_agent_1' not in invocation_context.end_of_agents
assert 'sub_agent_2' not in invocation_context.agent_states
assert 'sub_agent_2' not in invocation_context.end_of_agents
assert 'sub_sub_agent_1' not in invocation_context.agent_states
assert 'sub_sub_agent_1' not in invocation_context.end_of_agents
class TestFindMatchingFunctionCall:
"""Test suite for find_matching_function_call."""
@pytest.fixture
def test_invocation_context(self):
"""Create a mock invocation context for testing."""
def _create_invocation_context(events):
return InvocationContext(
session_service=Mock(spec=BaseSessionService),
agent=Mock(spec=BaseAgent, name='agent'),
invocation_id='inv_1',
session=Mock(spec=Session, events=events),
)
return _create_invocation_context
def test_find_matching_function_call_found(self, test_invocation_context):
"""Tests that a matching function call is found."""
fc = Part.from_function_call(name='some_tool', args={})
fc.function_call.id = 'test_function_call_id'
fc_event = Event(
invocation_id='inv_1',
author='agent',
content=testing_utils.ModelContent([fc]),
)
fr = Part.from_function_response(
name='some_tool', response={'result': 'ok'}
)
fr.function_response.id = 'test_function_call_id'
fr_event = Event(
invocation_id='inv_1',
author='agent',
content=Content(role='user', parts=[fr]),
)
invocation_context = test_invocation_context([fc_event, fr_event])
matching_fc_event = invocation_context._find_matching_function_call(
fr_event
)
assert testing_utils.simplify_content(
matching_fc_event.content
) == testing_utils.simplify_content(fc_event.content)
def test_find_matching_function_call_not_found(self, test_invocation_context):
"""Tests that no matching function call is returned if id doesn't match."""
fc = Part.from_function_call(name='some_tool', args={})
fc.function_call.id = 'another_function_call_id'
fc_event = Event(
invocation_id='inv_1',
author='agent',
content=testing_utils.ModelContent([fc]),
)
fr = Part.from_function_response(
name='some_tool', response={'result': 'ok'}
)
fr.function_response.id = 'test_function_call_id'
fr_event = Event(
invocation_id='inv_1',
author='agent',
content=Content(role='user', parts=[fr]),
)
invocation_context = test_invocation_context([fc_event, fr_event])
match = invocation_context._find_matching_function_call(fr_event)
assert match is None
def test_find_matching_function_call_no_call_events(
self, test_invocation_context
):
"""Tests that no matching function call is returned if there are no call events."""
fr = Part.from_function_response(
name='some_tool', response={'result': 'ok'}
)
fr.function_response.id = 'test_function_call_id'
fr_event = Event(
invocation_id='inv_1',
author='agent',
content=Content(role='user', parts=[fr]),
)
invocation_context = test_invocation_context([fr_event])
match = invocation_context._find_matching_function_call(fr_event)
assert match is None
def test_find_matching_function_call_no_response_in_event(
self, test_invocation_context
):
"""Tests result is None if function_response_event has no function response."""
fr_event_no_fr = Event(
author='agent',
content=Content(role='user', parts=[Part(text='user message')]),
)
fc = Part.from_function_call(name='some_tool', args={})
fc.function_call.id = 'test_function_call_id'
fc_event = Event(
invocation_id='inv_1',
author='agent',
content=testing_utils.ModelContent([fc]),
)
fr = Part.from_function_response(
name='some_tool', response={'result': 'ok'}
)
fr.function_response.id = 'test_function_call_id'
fr_event = Event(
invocation_id='inv_1',
author='agent',
content=Content(role='user', parts=[Part(text='user message')]),
)
invocation_context = test_invocation_context([fc_event, fr_event])
match = invocation_context._find_matching_function_call(fr_event_no_fr)
assert match is None
def test_stamp_event_branch_context_preserves_isolation_scope(
self, test_invocation_context
):
"""Tests stamp_event_branch_context does not overwrite existing isolation_scope with None."""
fc = Part.from_function_call(name='some_tool', args={})
fc.function_call.id = 'test_function_call_id'
fc_event = Event(
invocation_id='inv_1',
author='agent',
branch='root@1',
isolation_scope=None, # Coordinator FC has None scope
content=testing_utils.ModelContent([fc]),
)
fr = Part.from_function_response(
name='some_tool', response={'result': 'ok'}
)
fr.function_response.id = 'test_function_call_id'
fr_event = Event(
invocation_id='inv_1',
author='agent',
isolation_scope='task_123', # Pre-populated active task scope
content=Content(role='user', parts=[fr]),
)
invocation_context = test_invocation_context([fc_event, fr_event])
invocation_context.stamp_event_branch_context(fr_event)
assert fr_event.branch == 'root@1'
assert fr_event.isolation_scope == 'task_123'
def test_stamp_event_branch_context_does_not_overwrite_existing_scope(
self, test_invocation_context
):
"""Tests stamp_event_branch_context does not overwrite existing isolation_scope if set."""
fc = Part.from_function_call(name='some_tool', args={})
fc.function_call.id = 'test_function_call_id'
fc_event = Event(
invocation_id='inv_1',
author='agent',
branch='root@1',
isolation_scope='task_456', # Function call has isolation scope
content=testing_utils.ModelContent([fc]),
)
fr = Part.from_function_response(
name='some_tool', response={'result': 'ok'}
)
fr.function_response.id = 'test_function_call_id'
fr_event = Event(
invocation_id='inv_1',
author='agent',
isolation_scope='task_123', # Pre-populated active task scope
content=Content(role='user', parts=[fr]),
)
invocation_context = test_invocation_context([fc_event, fr_event])
invocation_context.stamp_event_branch_context(fr_event)
assert fr_event.branch == 'root@1'
assert fr_event.isolation_scope == 'task_123'
def test_find_matching_function_call_when_response_is_not_last_event(
self, test_invocation_context
):
"""Tests that matching function call is found even when response is not the last event in history."""
fc = Part.from_function_call(name='some_tool', args={})
fc.function_call.id = 'test_function_call_id'
fc_event = Event(
invocation_id='inv_1',
author='agent',
content=testing_utils.ModelContent([fc]),
)
fr = Part.from_function_response(
name='some_tool', response={'result': 'ok'}
)
fr.function_response.id = 'test_function_call_id'
fr_event = Event(
invocation_id='inv_1',
author='agent',
content=Content(role='user', parts=[fr]),
)
# Add a subsequent event to the history so that fr_event is NOT the last one
subsequent_event = Event(
invocation_id='inv_1',
author='user',
content=Content(role='user', parts=[Part(text='next user message')]),
)
invocation_context = test_invocation_context(
[fc_event, fr_event, subsequent_event]
)
matching_fc_event = invocation_context._find_matching_function_call(
fr_event
)
assert testing_utils.simplify_content(
matching_fc_event.content
) == testing_utils.simplify_content(fc_event.content)
@@ -0,0 +1,159 @@
# 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 _event_queue and _enqueue_event on InvocationContext."""
from __future__ import annotations
import asyncio
from google.adk.agents.invocation_context import InvocationContext
from google.adk.agents.llm_agent import LlmAgent
from google.adk.events.event import Event
from google.adk.sessions.in_memory_session_service import InMemorySessionService
import pytest
async def _create_ic_with_queue() -> InvocationContext:
"""Create a minimal InvocationContext with _event_queue set."""
agent = LlmAgent(
name='test_agent',
model='gemini-2.5-flash',
instruction='test',
)
session_service = InMemorySessionService()
session = await session_service.create_session(
app_name='test_app', user_id='test_user'
)
ic = InvocationContext(
invocation_id='test_invocation',
agent=agent,
session=session,
session_service=session_service,
)
ic._event_queue = asyncio.Queue()
return ic
async def _create_ic_without_queue() -> InvocationContext:
"""Create a minimal InvocationContext without _event_queue."""
agent = LlmAgent(
name='test_agent',
model='gemini-2.5-flash',
instruction='test',
)
session_service = InMemorySessionService()
session = await session_service.create_session(
app_name='test_app', user_id='test_user'
)
return InvocationContext(
invocation_id='test_invocation',
agent=agent,
session=session,
session_service=session_service,
)
@pytest.mark.asyncio
async def test_non_partial_event_blocks_until_processed() -> None:
"""A non-partial event should block _enqueue_event until the consumer
signals processed."""
ic: InvocationContext = await _create_ic_with_queue()
event: Event = Event(id=Event.new_id(), author='test')
completed: bool = False
async def consumer() -> None:
nonlocal completed
ev, processed = await ic._event_queue.get()
assert ev is event
assert processed is not None
processed.set()
completed = True
consumer_task: asyncio.Task = asyncio.create_task(consumer())
await ic._enqueue_event(event)
await consumer_task
assert completed, 'Consumer should have processed the event.'
@pytest.mark.asyncio
async def test_partial_event_does_not_block() -> None:
"""A partial event should not block — it returns immediately
without waiting for a processed signal."""
ic: InvocationContext = await _create_ic_with_queue()
event: Event = Event(id=Event.new_id(), author='test', partial=True)
await ic._enqueue_event(event)
assert not ic._event_queue.empty()
ev, processed = await ic._event_queue.get()
assert ev is event
assert processed is None, 'Partial events should have no processed signal.'
@pytest.mark.asyncio
async def test_events_arrive_in_order() -> None:
"""Multiple partial events should arrive on the queue in order."""
ic: InvocationContext = await _create_ic_with_queue()
events: list[Event] = [
Event(id=Event.new_id(), author=f'test_{i}', partial=True)
for i in range(5)
]
for event in events:
await ic._enqueue_event(event)
for i in range(5):
ev, _ = await ic._event_queue.get()
assert ev.author == f'test_{i}'
@pytest.mark.asyncio
async def test_enqueue_event_raises_when_queue_not_set() -> None:
"""_enqueue_event should raise RuntimeError if _event_queue is None."""
ic: InvocationContext = await _create_ic_without_queue()
event: Event = Event(id=Event.new_id(), author='test')
with pytest.raises(RuntimeError, match='_event_queue is not set'):
await ic._enqueue_event(event)
@pytest.mark.asyncio
async def test_non_partial_event_waits_for_signal() -> None:
"""Verify that _enqueue_event for a non-partial event actually waits —
it should not complete before the consumer signals."""
ic: InvocationContext = await _create_ic_with_queue()
event: Event = Event(id=Event.new_id(), author='test')
emit_done: bool = False
async def emitter() -> None:
nonlocal emit_done
await ic._enqueue_event(event)
emit_done = True
emit_task: asyncio.Task = asyncio.create_task(emitter())
# Give the emitter a chance to run.
await asyncio.sleep(0.01)
assert not emit_done, '_enqueue_event should still be waiting.'
# Now consume and signal.
_, processed = await ic._event_queue.get()
processed.set()
await emit_task
assert emit_done, '_enqueue_event should complete after signal.'
@@ -0,0 +1,264 @@
# 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 unittest.mock import MagicMock
import pytest
# Skip all tests in this module if LangGraph dependencies are not available
LANGGRAPH_AVAILABLE = True
try:
from google.adk.agents.invocation_context import InvocationContext
from google.adk.agents.langgraph_agent import LangGraphAgent
from google.adk.events.event import Event
from google.adk.plugins.plugin_manager import PluginManager
from google.genai import types
from langchain_core.messages import AIMessage
from langchain_core.messages import HumanMessage
from langchain_core.messages import SystemMessage
from langgraph.graph.graph import CompiledGraph
except ImportError:
LANGGRAPH_AVAILABLE = False
# IMPORTANT: Dummy classes are REQUIRED in this file but NOT in A2A test files.
# Here's why this file is different from A2A test files:
#
# 1. MODULE-LEVEL USAGE IN DECORATORS:
# This file uses @pytest.mark.parametrize decorator with complex nested structures
# that directly reference imported types like Event(), types.Content(), types.Part.from_text().
# These decorator expressions are evaluated during MODULE COMPILATION TIME,
# not during test execution time.
#
# 2. A2A TEST FILES PATTERN:
# Most A2A test files only use imported types within test method bodies:
# - Inside test functions: def test_something(): Message(...)
# - These are evaluated during TEST EXECUTION TIME when tests are skipped
# - No NameError occurs because skipped tests don't execute their bodies
#
# 3. WHAT HAPPENS WITHOUT DUMMIES:
# If we remove dummy classes from this file:
# - Python tries to compile the @pytest.mark.parametrize decorator
# - It encounters Event(...), types.Content(...), etc.
# - These names are undefined → NameError during module compilation
# - Test collection fails before pytest.mark.skipif can even run
#
# 4. WHY DUMMIES WORK:
# - DummyTypes() can be called like Event() → returns DummyTypes instance
# - DummyTypes.__getattr__ handles types.Content → returns DummyTypes instance
# - DummyTypes.__call__ handles types.Part.from_text() → returns DummyTypes instance
# - The parametrize decorator gets dummy objects instead of real ones
# - Tests still get skipped due to pytestmark, so dummies never actually run
#
# 5. EXCEPTION CASES IN A2A FILES:
# A few A2A files DID need dummies initially because they had:
# - Type annotations: def create_helper(x: str) -> Message
# - But we removed those type annotations to eliminate the need for dummies
#
# This file cannot avoid dummies because the parametrize decorator usage
# is fundamental to the test structure and cannot be easily refactored.
class DummyTypes:
def __getattr__(self, name):
return DummyTypes()
def __call__(self, *args, **kwargs):
return DummyTypes()
InvocationContext = DummyTypes()
LangGraphAgent = DummyTypes()
Event = DummyTypes()
PluginManager = DummyTypes()
types = (
DummyTypes()
) # Must support chained calls like types.Content(), types.Part.from_text()
AIMessage = DummyTypes()
HumanMessage = DummyTypes()
SystemMessage = DummyTypes()
CompiledGraph = DummyTypes()
pytestmark = pytest.mark.skipif(
not LANGGRAPH_AVAILABLE, reason="LangGraph dependencies not available"
)
@pytest.mark.parametrize(
"checkpointer_value, events_list, expected_messages",
[
(
MagicMock(),
[
Event(
invocation_id="test_invocation_id",
author="user",
content=types.Content(
role="user",
parts=[types.Part.from_text(text="test prompt")],
),
),
Event(
invocation_id="test_invocation_id",
author="root_agent",
content=types.Content(
role="model",
parts=[types.Part.from_text(text="(some delegation)")],
),
),
],
[
SystemMessage(content="test system prompt"),
HumanMessage(content="test prompt"),
],
),
(
None,
[
Event(
invocation_id="test_invocation_id",
author="user",
content=types.Content(
role="user",
parts=[types.Part.from_text(text="user prompt 1")],
),
),
Event(
invocation_id="test_invocation_id",
author="root_agent",
content=types.Content(
role="model",
parts=[
types.Part.from_text(text="root agent response")
],
),
),
Event(
invocation_id="test_invocation_id",
author="weather_agent",
content=types.Content(
role="model",
parts=[
types.Part.from_text(text="weather agent response")
],
),
),
Event(
invocation_id="test_invocation_id",
author="user",
content=types.Content(
role="user",
parts=[types.Part.from_text(text="user prompt 2")],
),
),
],
[
SystemMessage(content="test system prompt"),
HumanMessage(content="user prompt 1"),
AIMessage(content="weather agent response"),
HumanMessage(content="user prompt 2"),
],
),
(
MagicMock(),
[
Event(
invocation_id="test_invocation_id",
author="user",
content=types.Content(
role="user",
parts=[types.Part.from_text(text="user prompt 1")],
),
),
Event(
invocation_id="test_invocation_id",
author="root_agent",
content=types.Content(
role="model",
parts=[
types.Part.from_text(text="root agent response")
],
),
),
Event(
invocation_id="test_invocation_id",
author="weather_agent",
content=types.Content(
role="model",
parts=[
types.Part.from_text(text="weather agent response")
],
),
),
Event(
invocation_id="test_invocation_id",
author="user",
content=types.Content(
role="user",
parts=[types.Part.from_text(text="user prompt 2")],
),
),
],
[
SystemMessage(content="test system prompt"),
HumanMessage(content="user prompt 2"),
],
),
],
)
@pytest.mark.asyncio
async def test_langgraph_agent(
checkpointer_value, events_list, expected_messages
):
mock_graph = MagicMock(spec=CompiledGraph)
mock_graph_state = MagicMock()
mock_graph_state.values = {}
mock_graph.get_state.return_value = mock_graph_state
mock_graph.checkpointer = checkpointer_value
mock_graph.invoke.return_value = {
"messages": [AIMessage(content="test response")]
}
mock_parent_context = MagicMock(spec=InvocationContext)
mock_parent_context._state_schema = None
mock_session = MagicMock()
mock_parent_context.session = mock_session
mock_parent_context.user_content = types.Content(
role="user", parts=[types.Part.from_text(text="test prompt")]
)
mock_parent_context.branch = "parent_agent"
mock_parent_context.end_invocation = False
mock_session.events = events_list
mock_parent_context.invocation_id = "test_invocation_id"
mock_parent_context.model_copy.return_value = mock_parent_context
mock_parent_context.plugin_manager = PluginManager(plugins=[])
weather_agent = LangGraphAgent(
name="weather_agent",
description="A agent that answers weather questions",
instruction="test system prompt",
graph=mock_graph,
)
result_event = None
async for event in weather_agent.run_async(mock_parent_context):
result_event = event
assert result_event.author == "weather_agent"
assert result_event.content.parts[0].text == "test response"
mock_graph.invoke.assert_called_once()
mock_graph.invoke.assert_called_with(
{"messages": expected_messages},
{"configurable": {"thread_id": mock_session.id}},
)
@@ -0,0 +1,81 @@
# 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 unittest.mock import AsyncMock
from unittest.mock import MagicMock
from unittest.mock import patch
from google.adk.agents.live_request_queue import LiveRequest
from google.adk.agents.live_request_queue import LiveRequestQueue
from google.genai import types
import pytest
@pytest.mark.asyncio
async def test_close_queue():
queue = LiveRequestQueue()
with patch.object(queue._queue, "put_nowait") as mock_put_nowait:
queue.close()
mock_put_nowait.assert_called_once_with(LiveRequest(close=True))
def test_send_content():
queue = LiveRequestQueue()
content = MagicMock(spec=types.Content)
with patch.object(queue._queue, "put_nowait") as mock_put_nowait:
queue.send_content(content)
mock_put_nowait.assert_called_once_with(LiveRequest(content=content))
def test_send_content_sets_partial():
queue = LiveRequestQueue()
content = MagicMock(spec=types.Content)
with patch.object(queue._queue, "put_nowait") as mock_put_nowait:
queue.send_content(content, partial=True)
mock_put_nowait.assert_called_once_with(
LiveRequest(content=content, partial=True)
)
def test_send_realtime():
queue = LiveRequestQueue()
blob = MagicMock(spec=types.Blob)
with patch.object(queue._queue, "put_nowait") as mock_put_nowait:
queue.send_realtime(blob)
mock_put_nowait.assert_called_once_with(LiveRequest(blob=blob))
def test_send():
queue = LiveRequestQueue()
req = LiveRequest(content=MagicMock(spec=types.Content))
with patch.object(queue._queue, "put_nowait") as mock_put_nowait:
queue.send(req)
mock_put_nowait.assert_called_once_with(req)
@pytest.mark.asyncio
async def test_get():
queue = LiveRequestQueue()
res = MagicMock(spec=types.Content)
with patch.object(queue._queue, "get", return_value=res) as mock_get:
result = await queue.get()
assert result == res
mock_get.assert_called_once()
@@ -0,0 +1,210 @@
# 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 typing import Any
from typing import Optional
from google.adk.agents.callback_context import CallbackContext
from google.adk.agents.llm_agent import Agent
from google.adk.models.llm_request import LlmRequest
from google.adk.models.llm_response import LlmResponse
from google.genai import types
from pydantic import BaseModel
import pytest
from .. import testing_utils
class MockBeforeModelCallback(BaseModel):
mock_response: str
def __call__(
self,
callback_context: CallbackContext,
llm_request: LlmRequest,
) -> LlmResponse:
return LlmResponse(
content=testing_utils.ModelContent(
[types.Part.from_text(text=self.mock_response)]
)
)
class MockAfterModelCallback(BaseModel):
mock_response: str
def __call__(
self,
callback_context: CallbackContext,
llm_response: LlmResponse,
) -> LlmResponse:
return LlmResponse(
content=testing_utils.ModelContent(
[types.Part.from_text(text=self.mock_response)]
)
)
class MockAsyncBeforeModelCallback(BaseModel):
mock_response: str
async def __call__(
self,
callback_context: CallbackContext,
llm_request: LlmRequest,
) -> LlmResponse:
return LlmResponse(
content=testing_utils.ModelContent(
[types.Part.from_text(text=self.mock_response)]
)
)
class MockAsyncAfterModelCallback(BaseModel):
mock_response: str
async def __call__(
self,
callback_context: CallbackContext,
llm_response: LlmResponse,
) -> LlmResponse:
return LlmResponse(
content=testing_utils.ModelContent(
[types.Part.from_text(text=self.mock_response)]
)
)
def noop_callback(**kwargs) -> Optional[LlmResponse]:
pass
async def async_noop_callback(**kwargs) -> Optional[LlmResponse]:
pass
@pytest.mark.asyncio
async def test_before_model_callback():
responses = ['model_response']
mock_model = testing_utils.MockModel.create(responses=responses)
agent = Agent(
name='root_agent',
model=mock_model,
before_model_callback=MockBeforeModelCallback(
mock_response='before_model_callback'
),
)
runner = testing_utils.TestInMemoryRunner(agent)
assert testing_utils.simplify_events(
await runner.run_async_with_new_session('test')
) == [
('root_agent', 'before_model_callback'),
]
@pytest.mark.asyncio
async def test_before_model_callback_noop():
responses = ['model_response']
mock_model = testing_utils.MockModel.create(responses=responses)
agent = Agent(
name='root_agent',
model=mock_model,
before_model_callback=noop_callback,
)
runner = testing_utils.TestInMemoryRunner(agent)
assert testing_utils.simplify_events(
await runner.run_async_with_new_session('test')
) == [
('root_agent', 'model_response'),
]
@pytest.mark.asyncio
async def test_after_model_callback():
responses = ['model_response']
mock_model = testing_utils.MockModel.create(responses=responses)
agent = Agent(
name='root_agent',
model=mock_model,
after_model_callback=MockAfterModelCallback(
mock_response='after_model_callback'
),
)
runner = testing_utils.TestInMemoryRunner(agent)
assert testing_utils.simplify_events(
await runner.run_async_with_new_session('test')
) == [
('root_agent', 'after_model_callback'),
]
@pytest.mark.asyncio
async def test_async_before_model_callback():
responses = ['model_response']
mock_model = testing_utils.MockModel.create(responses=responses)
agent = Agent(
name='root_agent',
model=mock_model,
before_model_callback=MockAsyncBeforeModelCallback(
mock_response='async_before_model_callback'
),
)
runner = testing_utils.TestInMemoryRunner(agent)
assert testing_utils.simplify_events(
await runner.run_async_with_new_session('test')
) == [
('root_agent', 'async_before_model_callback'),
]
@pytest.mark.asyncio
async def test_async_before_model_callback_noop():
responses = ['model_response']
mock_model = testing_utils.MockModel.create(responses=responses)
agent = Agent(
name='root_agent',
model=mock_model,
before_model_callback=async_noop_callback,
)
runner = testing_utils.TestInMemoryRunner(agent)
assert testing_utils.simplify_events(
await runner.run_async_with_new_session('test')
) == [
('root_agent', 'model_response'),
]
@pytest.mark.asyncio
async def test_async_after_model_callback():
responses = ['model_response']
mock_model = testing_utils.MockModel.create(responses=responses)
agent = Agent(
name='root_agent',
model=mock_model,
after_model_callback=MockAsyncAfterModelCallback(
mock_response='async_after_model_callback'
),
)
runner = testing_utils.TestInMemoryRunner(agent)
assert testing_utils.simplify_events(
await runner.run_async_with_new_session('test')
) == [
('root_agent', 'async_after_model_callback'),
]
@@ -0,0 +1,90 @@
# 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 enhanced error messages in agent handling."""
from google.adk.agents.llm_agent import LlmAgent
import pytest
def test_agent_not_found_enhanced_error():
"""Verify enhanced error message for agent not found."""
root_agent = LlmAgent(
name='root',
model='gemini-2.5-flash',
sub_agents=[
LlmAgent(name='agent_a', model='gemini-2.5-flash'),
LlmAgent(name='agent_b', model='gemini-2.5-flash'),
],
)
with pytest.raises(ValueError) as exc_info:
root_agent._LlmAgent__get_agent_to_run('nonexistent_agent')
error_msg = str(exc_info.value)
# Verify error message components
assert 'nonexistent_agent' in error_msg
assert 'Available agents:' in error_msg
assert 'agent_a' in error_msg
assert 'agent_b' in error_msg
assert 'Possible causes:' in error_msg
assert 'Suggested fixes:' in error_msg
def test_agent_tree_traversal():
"""Verify agent tree traversal helper works correctly."""
root_agent = LlmAgent(
name='orchestrator',
model='gemini-2.5-flash',
sub_agents=[
LlmAgent(
name='parent_agent',
model='gemini-2.5-flash',
sub_agents=[
LlmAgent(name='child_agent', model='gemini-2.5-flash'),
],
),
],
)
available_agents = root_agent._get_available_agent_names()
# Verify all agents in tree are found
assert 'orchestrator' in available_agents
assert 'parent_agent' in available_agents
assert 'child_agent' in available_agents
assert len(available_agents) == 3
def test_agent_not_found_shows_all_agents():
"""Verify error message shows all agents (no truncation)."""
# Create 100 sub-agents
sub_agents = [
LlmAgent(name=f'agent_{i}', model='gemini-2.5-flash') for i in range(100)
]
root_agent = LlmAgent(
name='root', model='gemini-2.5-flash', sub_agents=sub_agents
)
with pytest.raises(ValueError) as exc_info:
root_agent._LlmAgent__get_agent_to_run('nonexistent')
error_msg = str(exc_info.value)
# Verify all agents are shown (no truncation)
assert 'agent_0' in error_msg # First agent shown
assert 'agent_99' in error_msg # Last agent also shown
assert 'showing first 20 of' not in error_msg # No truncation message
@@ -0,0 +1,605 @@
# 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.
"""Unit tests for canonical_xxx fields in LlmAgent."""
import logging
from typing import Any
from typing import Optional
from unittest import mock
from google.adk.agents.callback_context import CallbackContext
from google.adk.agents.invocation_context import InvocationContext
from google.adk.agents.llm_agent import LlmAgent
from google.adk.agents.readonly_context import ReadonlyContext
from google.adk.models.anthropic_llm import Claude
from google.adk.models.google_llm import Gemini
from google.adk.models.lite_llm import LiteLlm
from google.adk.models.llm_request import LlmRequest
from google.adk.models.registry import LLMRegistry
from google.adk.planners.built_in_planner import BuiltInPlanner
from google.adk.sessions.in_memory_session_service import InMemorySessionService
from google.adk.tools.google_search_tool import google_search
from google.adk.tools.google_search_tool import GoogleSearchTool
from google.adk.tools.vertex_ai_search_tool import VertexAiSearchTool
from google.genai import types
from pydantic import BaseModel
import pytest
async def _create_readonly_context(
agent: LlmAgent, state: Optional[dict[str, Any]] = None
) -> ReadonlyContext:
session_service = InMemorySessionService()
session = await session_service.create_session(
app_name='test_app', user_id='test_user', state=state
)
invocation_context = InvocationContext(
invocation_id='test_id',
agent=agent,
session=session,
session_service=session_service,
)
return ReadonlyContext(invocation_context)
@pytest.mark.parametrize(
('default_model', 'expected_model_name', 'expected_model_type'),
[
(LlmAgent.DEFAULT_MODEL, LlmAgent.DEFAULT_MODEL, Gemini),
('gemini-2.5-flash', 'gemini-2.5-flash', Gemini),
],
)
def test_canonical_model_default_fallback(
default_model, expected_model_name, expected_model_type
):
original_default = LlmAgent._default_model
LlmAgent.set_default_model(default_model)
try:
agent = LlmAgent(name='test_agent')
assert isinstance(agent.canonical_model, expected_model_type)
assert agent.canonical_model.model == expected_model_name
finally:
LlmAgent.set_default_model(original_default)
def test_canonical_model_str():
agent = LlmAgent(name='test_agent', model='gemini-pro')
assert agent.canonical_model.model == 'gemini-pro'
def test_canonical_model_llm():
llm = LLMRegistry.new_llm('gemini-pro')
agent = LlmAgent(name='test_agent', model=llm)
assert agent.canonical_model == llm
def test_canonical_model_inherit():
sub_agent = LlmAgent(name='sub_agent')
parent_agent = LlmAgent(
name='parent_agent', model='gemini-pro', sub_agents=[sub_agent]
)
assert sub_agent.canonical_model == parent_agent.canonical_model
def test_canonical_live_model_default_fallback():
original_default = LlmAgent._default_live_model
LlmAgent.set_default_live_model('gemini-2.0-flash')
try:
agent = LlmAgent(name='test_agent')
assert agent.canonical_live_model.model == 'gemini-2.0-flash'
finally:
LlmAgent.set_default_live_model(original_default)
def test_canonical_live_model_str():
agent = LlmAgent(name='test_agent', model='gemini-pro')
assert agent.canonical_live_model.model == 'gemini-pro'
def test_canonical_live_model_llm():
llm = LLMRegistry.new_llm('gemini-pro')
agent = LlmAgent(name='test_agent', model=llm)
assert agent.canonical_live_model == llm
def test_canonical_live_model_inherit():
sub_agent = LlmAgent(name='sub_agent')
parent_agent = LlmAgent(
name='parent_agent', model='gemini-pro', sub_agents=[sub_agent]
)
assert sub_agent.canonical_live_model == parent_agent.canonical_live_model
async def test_canonical_instruction_str():
agent = LlmAgent(name='test_agent', instruction='instruction')
ctx = await _create_readonly_context(agent)
canonical_instruction, bypass_state_injection = (
await agent.canonical_instruction(ctx)
)
assert canonical_instruction == 'instruction'
assert not bypass_state_injection
async def test_canonical_instruction():
def _instruction_provider(ctx: ReadonlyContext) -> str:
return f'instruction: {ctx.state["state_var"]}'
agent = LlmAgent(name='test_agent', instruction=_instruction_provider)
ctx = await _create_readonly_context(
agent, state={'state_var': 'state_value'}
)
canonical_instruction, bypass_state_injection = (
await agent.canonical_instruction(ctx)
)
assert canonical_instruction == 'instruction: state_value'
assert bypass_state_injection
async def test_async_canonical_instruction():
async def _instruction_provider(ctx: ReadonlyContext) -> str:
return f'instruction: {ctx.state["state_var"]}'
agent = LlmAgent(name='test_agent', instruction=_instruction_provider)
ctx = await _create_readonly_context(
agent, state={'state_var': 'state_value'}
)
canonical_instruction, bypass_state_injection = (
await agent.canonical_instruction(ctx)
)
assert canonical_instruction == 'instruction: state_value'
assert bypass_state_injection
async def test_canonical_global_instruction_str():
agent = LlmAgent(name='test_agent', global_instruction='global instruction')
ctx = await _create_readonly_context(agent)
canonical_instruction, bypass_state_injection = (
await agent.canonical_global_instruction(ctx)
)
assert canonical_instruction == 'global instruction'
assert not bypass_state_injection
async def test_canonical_global_instruction():
def _global_instruction_provider(ctx: ReadonlyContext) -> str:
return f'global instruction: {ctx.state["state_var"]}'
agent = LlmAgent(
name='test_agent', global_instruction=_global_instruction_provider
)
ctx = await _create_readonly_context(
agent, state={'state_var': 'state_value'}
)
canonical_global_instruction, bypass_state_injection = (
await agent.canonical_global_instruction(ctx)
)
assert canonical_global_instruction == 'global instruction: state_value'
assert bypass_state_injection
async def test_async_canonical_global_instruction():
async def _global_instruction_provider(ctx: ReadonlyContext) -> str:
return f'global instruction: {ctx.state["state_var"]}'
agent = LlmAgent(
name='test_agent', global_instruction=_global_instruction_provider
)
ctx = await _create_readonly_context(
agent, state={'state_var': 'state_value'}
)
canonical_global_instruction, bypass_state_injection = (
await agent.canonical_global_instruction(ctx)
)
assert canonical_global_instruction == 'global instruction: state_value'
assert bypass_state_injection
def test_output_schema_with_sub_agents_will_not_throw():
class Schema(BaseModel):
pass
sub_agent = LlmAgent(
name='sub_agent',
)
agent = LlmAgent(
name='test_agent',
output_schema=Schema,
sub_agents=[sub_agent],
)
# Transfer is not disabled
assert not agent.disallow_transfer_to_parent
assert not agent.disallow_transfer_to_peers
assert agent.output_schema == Schema
assert agent.sub_agents == [sub_agent]
def test_output_schema_with_tools_will_not_throw():
class Schema(BaseModel):
pass
def _a_tool():
pass
LlmAgent(
name='test_agent',
output_schema=Schema,
tools=[_a_tool],
)
def test_before_model_callback():
def _before_model_callback(
callback_context: CallbackContext,
llm_request: LlmRequest,
) -> None:
return None
agent = LlmAgent(
name='test_agent', before_model_callback=_before_model_callback
)
# TODO: add more logic assertions later.
assert agent.before_model_callback is not None
def test_validate_generate_content_config_thinking_config_allow():
"""Tests that thinking_config is now allowed directly in the agent init."""
agent = LlmAgent(
name='test_agent',
generate_content_config=types.GenerateContentConfig(
thinking_config=types.ThinkingConfig(include_thoughts=True)
),
)
assert agent.generate_content_config.thinking_config.include_thoughts is True
def test_thinking_config_precedence_warning():
"""Tests that a UserWarning is issued when both manual config and planner exist."""
config = types.GenerateContentConfig(
thinking_config=types.ThinkingConfig(include_thoughts=True)
)
planner = BuiltInPlanner(
thinking_config=types.ThinkingConfig(include_thoughts=True)
)
with pytest.warns(
UserWarning, match="planner's configuration will take precedence"
):
LlmAgent(name='test_agent', generate_content_config=config, planner=planner)
def test_validate_generate_content_config_tools_throw():
"""Tests that tools cannot be set directly in config."""
with pytest.raises(ValueError):
_ = LlmAgent(
name='test_agent',
generate_content_config=types.GenerateContentConfig(
tools=[types.Tool(function_declarations=[])]
),
)
def test_validate_generate_content_config_system_instruction_throw():
"""Tests that system instructions cannot be set directly in config."""
with pytest.raises(ValueError):
_ = LlmAgent(
name='test_agent',
generate_content_config=types.GenerateContentConfig(
system_instruction='system instruction'
),
)
def test_validate_generate_content_config_response_schema_throw():
"""Tests that response schema cannot be set directly in config."""
class Schema(BaseModel):
pass
with pytest.raises(ValueError):
_ = LlmAgent(
name='test_agent',
generate_content_config=types.GenerateContentConfig(
response_schema=Schema
),
)
def test_allow_transfer_by_default():
sub_agent = LlmAgent(name='sub_agent')
agent = LlmAgent(name='test_agent', sub_agents=[sub_agent])
assert not agent.disallow_transfer_to_parent
assert not agent.disallow_transfer_to_peers
# TODO(b/448114567): Remove TestCanonicalTools once the workaround
# is no longer needed.
class TestCanonicalTools:
"""Unit tests for canonical_tools in LlmAgent."""
@staticmethod
def _my_tool(sides: int) -> int:
return sides
async def test_handle_google_search_with_other_tools(self):
"""Test that google_search is wrapped into an agent."""
agent = LlmAgent(
name='test_agent',
model='gemini-pro',
tools=[
self._my_tool,
GoogleSearchTool(bypass_multi_tools_limit=True),
],
)
ctx = await _create_readonly_context(agent)
tools = await agent.canonical_tools(ctx)
assert len(tools) == 2
assert tools[0].name == '_my_tool'
assert tools[0].__class__.__name__ == 'FunctionTool'
assert tools[1].name == 'google_search_agent'
assert tools[1].__class__.__name__ == 'GoogleSearchAgentTool'
async def test_handle_google_search_with_other_tools_no_bypass(self):
"""Test that google_search is not wrapped into an agent."""
agent = LlmAgent(
name='test_agent',
model='gemini-pro',
tools=[
self._my_tool,
GoogleSearchTool(bypass_multi_tools_limit=False),
],
)
ctx = await _create_readonly_context(agent)
tools = await agent.canonical_tools(ctx)
assert len(tools) == 2
assert tools[0].name == '_my_tool'
assert tools[0].__class__.__name__ == 'FunctionTool'
assert tools[1].name == 'google_search'
assert tools[1].__class__.__name__ == 'GoogleSearchTool'
async def test_handle_google_search_only(self):
"""Test that google_search is not wrapped into an agent."""
agent = LlmAgent(
name='test_agent',
model='gemini-pro',
tools=[
google_search,
],
)
ctx = await _create_readonly_context(agent)
tools = await agent.canonical_tools(ctx)
assert len(tools) == 1
assert tools[0].name == 'google_search'
assert tools[0].__class__.__name__ == 'GoogleSearchTool'
async def test_function_tool_only(self):
"""Test that function tool is not affected."""
agent = LlmAgent(
name='test_agent',
model='gemini-pro',
tools=[
self._my_tool,
],
)
ctx = await _create_readonly_context(agent)
tools = await agent.canonical_tools(ctx)
assert len(tools) == 1
assert tools[0].name == '_my_tool'
assert tools[0].__class__.__name__ == 'FunctionTool'
@mock.patch(
'google.auth.default',
mock.MagicMock(return_value=('credentials', 'project')),
)
async def test_handle_vais_with_other_tools(self):
"""Test that VertexAiSearchTool is replaced with Discovery Engine Search."""
agent = LlmAgent(
name='test_agent',
model='gemini-pro',
tools=[
self._my_tool,
VertexAiSearchTool(
data_store_id='test_data_store_id',
bypass_multi_tools_limit=True,
),
],
)
ctx = await _create_readonly_context(agent)
tools = await agent.canonical_tools(ctx)
assert len(tools) == 2
assert tools[0].name == '_my_tool'
assert tools[0].__class__.__name__ == 'FunctionTool'
assert tools[1].name == 'discovery_engine_search'
assert tools[1].__class__.__name__ == 'DiscoveryEngineSearchTool'
async def test_handle_vais_with_other_tools_no_bypass(self):
"""Test that VertexAiSearchTool is not replaced."""
agent = LlmAgent(
name='test_agent',
model='gemini-pro',
tools=[
self._my_tool,
VertexAiSearchTool(
data_store_id='test_data_store_id',
bypass_multi_tools_limit=False,
),
],
)
ctx = await _create_readonly_context(agent)
tools = await agent.canonical_tools(ctx)
assert len(tools) == 2
assert tools[0].name == '_my_tool'
assert tools[0].__class__.__name__ == 'FunctionTool'
assert tools[1].name == 'vertex_ai_search'
assert tools[1].__class__.__name__ == 'VertexAiSearchTool'
async def test_handle_vais_only(self):
"""Test that VertexAiSearchTool is not wrapped into an agent."""
agent = LlmAgent(
name='test_agent',
model='gemini-pro',
tools=[
VertexAiSearchTool(data_store_id='test_data_store_id'),
],
)
ctx = await _create_readonly_context(agent)
tools = await agent.canonical_tools(ctx)
assert len(tools) == 1
assert tools[0].name == 'vertex_ai_search'
assert tools[0].__class__.__name__ == 'VertexAiSearchTool'
async def test_multiple_tools_resolution(self):
"""Test that multiple tools are resolved correctly."""
def _tool_1():
pass
def _tool_2():
pass
agent = LlmAgent(
name='test_agent',
model='gemini-pro',
tools=[_tool_1, _tool_2],
)
ctx = await _create_readonly_context(agent)
tools = await agent.canonical_tools(ctx)
assert len(tools) == 2
assert tools[0].name == '_tool_1'
assert tools[1].name == '_tool_2'
async def test_canonical_tools_graceful_degradation_on_toolset_error(self):
"""Test that canonical_tools returns tools from working toolsets when one fails."""
from google.adk.tools.base_tool import BaseTool
from google.adk.tools.base_toolset import BaseToolset
class FailingToolset(BaseToolset):
async def get_tools(self, readonly_context=None):
raise ConnectionError('MCP server unavailable')
class WorkingToolset(BaseToolset):
async def get_tools(self, readonly_context=None):
tool = mock.MagicMock(spec=BaseTool)
tool.name = 'working_tool'
tool._get_declaration = mock.MagicMock(return_value=None)
return [tool]
def _regular_tool():
pass
agent = LlmAgent(
name='test_agent',
model='gemini-pro',
tools=[_regular_tool, FailingToolset(), WorkingToolset()],
)
ctx = await _create_readonly_context(agent)
tools = await agent.canonical_tools(ctx)
# Should have the regular tool + working toolset tool, but not crash
assert len(tools) == 2
assert tools[0].name == '_regular_tool'
assert tools[1].name == 'working_tool'
# Tests for multi-provider model support via string model names
@pytest.mark.parametrize(
'model_name',
[
'gemini-2.5-flash',
'gemini-2.5-pro',
],
)
def test_agent_with_gemini_string_model(model_name):
"""Test that Agent accepts Gemini model strings and resolves to Gemini."""
agent = LlmAgent(name='test_agent', model=model_name)
assert isinstance(agent.canonical_model, Gemini)
assert agent.canonical_model.model == model_name
@pytest.mark.parametrize(
'model_name',
[
'claude-3-5-sonnet-v2@20241022',
'claude-sonnet-4@20250514',
],
)
def test_agent_with_claude_string_model(model_name):
"""Test that Agent accepts Claude model strings and resolves to Claude."""
agent = LlmAgent(name='test_agent', model=model_name)
assert isinstance(agent.canonical_model, Claude)
assert agent.canonical_model.model == model_name
@pytest.mark.parametrize(
'model_name',
[
'openai/gpt-4o',
'groq/llama3-70b-8192',
'anthropic/claude-3-opus-20240229',
],
)
def test_agent_with_litellm_string_model(model_name):
"""Test that Agent accepts LiteLLM provider strings."""
agent = LlmAgent(name='test_agent', model=model_name)
assert isinstance(agent.canonical_model, LiteLlm)
assert agent.canonical_model.model == model_name
def test_builtin_planner_overwrite_logging(caplog):
"""Tests that the planner logs an DEBUG message when overwriting a config."""
planner = BuiltInPlanner(
thinking_config=types.ThinkingConfig(include_thoughts=True)
)
# Create a request that already has a thinking_config
req = LlmRequest(
contents=[],
config=types.GenerateContentConfig(
thinking_config=types.ThinkingConfig(include_thoughts=True)
),
)
with caplog.at_level(
logging.DEBUG, logger='google_adk.google.adk.planners.built_in_planner'
):
planner.apply_thinking_config(req)
assert (
'Overwriting `thinking_config` from `generate_content_config`'
in caplog.text
)
@@ -0,0 +1,434 @@
# 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.
"""Unit tests for LlmAgent include_contents field behavior."""
from google.adk.agents.llm_agent import LlmAgent
from google.adk.agents.run_config import RunConfig
from google.adk.agents.sequential_agent import SequentialAgent
from google.genai import types
import pytest
from .. import testing_utils
@pytest.mark.asyncio
async def test_include_contents_default_behavior():
"""Test that include_contents='default' preserves conversation history including tool interactions."""
def simple_tool(message: str) -> dict:
return {"result": f"Tool processed: {message}"}
mock_model = testing_utils.MockModel.create(
responses=[
types.Part.from_function_call(
name="simple_tool", args={"message": "first"}
),
"First response",
types.Part.from_function_call(
name="simple_tool", args={"message": "second"}
),
"Second response",
]
)
agent = LlmAgent(
name="test_agent",
model=mock_model,
include_contents="default",
instruction="You are a helpful assistant",
tools=[simple_tool],
)
runner = testing_utils.InMemoryRunner(agent)
runner.run("First message")
runner.run("Second message")
# First turn requests
assert testing_utils.simplify_contents(mock_model.requests[0].contents) == [
("user", "First message")
]
assert testing_utils.simplify_contents(mock_model.requests[1].contents) == [
("user", "First message"),
(
"model",
types.Part.from_function_call(
name="simple_tool", args={"message": "first"}
),
),
(
"user",
types.Part.from_function_response(
name="simple_tool", response={"result": "Tool processed: first"}
),
),
]
# Second turn should include full conversation history
assert testing_utils.simplify_contents(mock_model.requests[2].contents) == [
("user", "First message"),
(
"model",
types.Part.from_function_call(
name="simple_tool", args={"message": "first"}
),
),
(
"user",
types.Part.from_function_response(
name="simple_tool", response={"result": "Tool processed: first"}
),
),
("model", "First response"),
("user", "Second message"),
]
# Second turn with tool should include full history + current tool interaction
assert testing_utils.simplify_contents(mock_model.requests[3].contents) == [
("user", "First message"),
(
"model",
types.Part.from_function_call(
name="simple_tool", args={"message": "first"}
),
),
(
"user",
types.Part.from_function_response(
name="simple_tool", response={"result": "Tool processed: first"}
),
),
("model", "First response"),
("user", "Second message"),
(
"model",
types.Part.from_function_call(
name="simple_tool", args={"message": "second"}
),
),
(
"user",
types.Part.from_function_response(
name="simple_tool", response={"result": "Tool processed: second"}
),
),
]
@pytest.mark.asyncio
async def test_include_contents_none_behavior():
"""Test that include_contents='none' excludes conversation history but includes current input."""
def simple_tool(message: str) -> dict:
return {"result": f"Tool processed: {message}"}
mock_model = testing_utils.MockModel.create(
responses=[
types.Part.from_function_call(
name="simple_tool", args={"message": "first"}
),
"First response",
"Second response",
]
)
agent = LlmAgent(
name="test_agent",
model=mock_model,
include_contents="none",
instruction="You are a helpful assistant",
tools=[simple_tool],
)
runner = testing_utils.InMemoryRunner(agent)
runner.run("First message")
runner.run("Second message")
# First turn behavior
assert testing_utils.simplify_contents(mock_model.requests[0].contents) == [
("user", "First message")
]
assert testing_utils.simplify_contents(mock_model.requests[1].contents) == [
("user", "First message"),
(
"model",
types.Part.from_function_call(
name="simple_tool", args={"message": "first"}
),
),
(
"user",
types.Part.from_function_response(
name="simple_tool", response={"result": "Tool processed: first"}
),
),
]
# Second turn should only have current input, no history
assert testing_utils.simplify_contents(mock_model.requests[2].contents) == [
("user", "Second message")
]
# System instruction and tools should be preserved
assert (
"You are a helpful assistant"
in mock_model.requests[0].config.system_instruction
)
assert len(mock_model.requests[0].config.tools) > 0
def test_model_input_context_is_sent_to_model_without_persisting_to_session():
mock_model = testing_utils.MockModel.create(responses=["Answer"])
agent = LlmAgent(name="test_agent", model=mock_model)
runner = testing_utils.InMemoryRunner(agent)
session = runner.session
list(
runner.runner.run(
user_id=session.user_id,
session_id=session.id,
new_message=testing_utils.get_user_content("Question"),
run_config=RunConfig(
model_input_context=[
types.UserContent("Relevant context for this turn")
]
),
)
)
assert testing_utils.simplify_contents(mock_model.requests[0].contents) == [
("user", "Relevant context for this turn"),
("user", "Question"),
]
assert testing_utils.simplify_events(runner.session.events) == [
("user", "Question"),
("test_agent", "Answer"),
]
def test_model_input_context_stays_before_user_message_after_tool_call():
def simple_tool(message: str) -> dict:
return {"result": f"Tool processed: {message}"}
mock_model = testing_utils.MockModel.create(
responses=[
types.Part.from_function_call(
name="simple_tool", args={"message": "payload"}
),
"Answer",
]
)
agent = LlmAgent(name="test_agent", model=mock_model, tools=[simple_tool])
runner = testing_utils.InMemoryRunner(agent)
session = runner.session
list(
runner.runner.run(
user_id=session.user_id,
session_id=session.id,
new_message=testing_utils.get_user_content("Question"),
run_config=RunConfig(
model_input_context=[
types.UserContent("Relevant context for this turn")
]
),
)
)
assert testing_utils.simplify_contents(mock_model.requests[0].contents) == [
("user", "Relevant context for this turn"),
("user", "Question"),
]
assert testing_utils.simplify_contents(mock_model.requests[1].contents) == [
("user", "Relevant context for this turn"),
("user", "Question"),
(
"model",
types.Part.from_function_call(
name="simple_tool", args={"message": "payload"}
),
),
(
"user",
types.Part.from_function_response(
name="simple_tool",
response={"result": "Tool processed: payload"},
),
),
]
assert testing_utils.simplify_events(runner.session.events) == [
("user", "Question"),
(
"test_agent",
types.Part.from_function_call(
name="simple_tool", args={"message": "payload"}
),
),
(
"test_agent",
types.Part.from_function_response(
name="simple_tool",
response={"result": "Tool processed: payload"},
),
),
("test_agent", "Answer"),
]
def test_model_input_context_with_include_contents_none_sub_agent():
agent1_model = testing_utils.MockModel.create(
responses=["Agent1 response: XYZ"]
)
agent1 = LlmAgent(name="agent1", model=agent1_model)
agent2_model = testing_utils.MockModel.create(
responses=["Agent2 final response"]
)
agent2 = LlmAgent(
name="agent2",
model=agent2_model,
include_contents="none",
)
sequential_agent = SequentialAgent(
name="sequential_test_agent", sub_agents=[agent1, agent2]
)
runner = testing_utils.InMemoryRunner(sequential_agent)
session = runner.session
list(
runner.runner.run(
user_id=session.user_id,
session_id=session.id,
new_message=testing_utils.get_user_content("Original user request"),
run_config=RunConfig(
model_input_context=[
types.UserContent("Relevant context for this turn")
]
),
)
)
assert testing_utils.simplify_contents(agent1_model.requests[0].contents) == [
("user", "Relevant context for this turn"),
("user", "Original user request"),
]
assert testing_utils.simplify_contents(agent2_model.requests[0].contents) == [
("user", "Relevant context for this turn"),
(
"user",
[
types.Part(text="For context:"),
types.Part(text="[agent1] said: Agent1 response: XYZ"),
],
),
]
def test_model_input_context_without_user_message_is_prepended_before_history():
mock_model = testing_utils.MockModel.create(
responses=["First answer", "Second answer"]
)
agent = LlmAgent(name="test_agent", model=mock_model)
runner = testing_utils.InMemoryRunner(agent)
session = runner.session
list(
runner.runner.run(
user_id=session.user_id,
session_id=session.id,
new_message=testing_utils.get_user_content("First question"),
)
)
# No new_message, so the invocation has no user content to anchor before
# (e.g. live mode or a re-run over existing history). The transient context
# falls back to the front of the request, before all prior history.
list(
runner.runner.run(
user_id=session.user_id,
session_id=session.id,
new_message=None,
run_config=RunConfig(
model_input_context=[
types.UserContent("Relevant context for this turn")
]
),
)
)
assert testing_utils.simplify_contents(mock_model.requests[1].contents) == [
("user", "Relevant context for this turn"),
("user", "First question"),
("model", "First answer"),
]
assert testing_utils.simplify_events(runner.session.events) == [
("user", "First question"),
("test_agent", "First answer"),
("test_agent", "Second answer"),
]
@pytest.mark.asyncio
async def test_include_contents_none_sequential_agents():
"""Test include_contents='none' with sequential agents."""
agent1_model = testing_utils.MockModel.create(
responses=["Agent1 response: XYZ"]
)
agent1 = LlmAgent(
name="agent1",
model=agent1_model,
instruction="You are Agent1",
)
agent2_model = testing_utils.MockModel.create(
responses=["Agent2 final response"]
)
agent2 = LlmAgent(
name="agent2",
model=agent2_model,
include_contents="none",
instruction="You are Agent2",
)
sequential_agent = SequentialAgent(
name="sequential_test_agent", sub_agents=[agent1, agent2]
)
runner = testing_utils.InMemoryRunner(sequential_agent)
events = runner.run("Original user request")
simplified_events = [event for event in events if event.content]
assert len(simplified_events) == 2
assert "Agent1 response" in str(simplified_events[0].content)
assert "Agent2 final response" in str(simplified_events[1].content)
# Agent1 sees original user request
agent1_contents = testing_utils.simplify_contents(
agent1_model.requests[0].contents
)
assert ("user", "Original user request") in agent1_contents
# Agent2 with include_contents='none' should not see original request
agent2_contents = testing_utils.simplify_contents(
agent2_model.requests[0].contents
)
assert not any(
"Original user request" in str(content) for _, content in agent2_contents
)
assert any(
"Agent1 response" in str(content) for _, content in agent2_contents
)
@@ -0,0 +1,481 @@
# 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))
@@ -0,0 +1,357 @@
# 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.
"""Unit tests for LlmAgent output saving functionality."""
import logging
from unittest.mock import patch
from google.adk.agents.callback_context import CallbackContext
from google.adk.agents.llm_agent import LlmAgent
from google.adk.events.event import Event
from google.adk.events.event_actions import EventActions
from google.genai import types
from pydantic import BaseModel
import pytest
from .. import testing_utils
class MockOutputSchema(BaseModel):
message: str
confidence: float
def create_test_event(
author: str = "test_agent",
content_text: str = "Hello world",
is_final: bool = True,
invocation_id: str = "test_invocation",
) -> Event:
"""Helper to create test events."""
# Create mock content
parts = [types.Part.from_text(text=content_text)] if content_text else []
content = types.Content(role="model", parts=parts) if parts else None
# Create event
event = Event(
invocation_id=invocation_id,
author=author,
content=content,
actions=EventActions(),
)
# Mock is_final_response if needed
if not is_final:
event.partial = True
return event
class TestLlmAgentOutputSave:
"""Test suite for LlmAgent output saving functionality."""
def test_maybe_save_output_to_state_skips_different_author(self, caplog):
"""Test that output is not saved when event author differs from agent name."""
# Set the LlmAgent logger to DEBUG level
llm_agent_logger = logging.getLogger(
"google_adk.google.adk.agents.llm_agent"
)
original_level = llm_agent_logger.level
llm_agent_logger.setLevel(logging.DEBUG)
try:
agent = LlmAgent(name="agent_a", output_key="result")
event = create_test_event(
author="agent_b", content_text="Response from B"
)
with caplog.at_level("DEBUG"):
agent._LlmAgent__maybe_save_output_to_state(event)
# Should not add anything to state_delta
assert len(event.actions.state_delta) == 0
# Should log the skip
assert (
"Skipping output save for agent agent_a: event authored by agent_b"
in caplog.text
)
finally:
# Restore original logger level
llm_agent_logger.setLevel(original_level)
def test_maybe_save_output_to_state_saves_same_author(self):
"""Test that output is saved when event author matches agent name."""
agent = LlmAgent(name="test_agent", output_key="result")
event = create_test_event(author="test_agent", content_text="Test response")
agent._LlmAgent__maybe_save_output_to_state(event)
# Should save to state_delta
assert event.actions.state_delta["result"] == "Test response"
def test_maybe_save_output_to_state_no_output_key(self):
"""Test that nothing is saved when output_key is not set."""
agent = LlmAgent(name="test_agent") # No output_key
event = create_test_event(author="test_agent", content_text="Test response")
agent._LlmAgent__maybe_save_output_to_state(event)
# Should not save anything
assert len(event.actions.state_delta) == 0
def test_maybe_save_output_to_state_not_final_response(self):
"""Test that output is not saved for non-final responses."""
agent = LlmAgent(name="test_agent", output_key="result")
event = create_test_event(
author="test_agent", content_text="Partial response", is_final=False
)
agent._LlmAgent__maybe_save_output_to_state(event)
# Should not save partial responses
assert len(event.actions.state_delta) == 0
def test_maybe_save_output_to_state_no_content(self):
"""Test that nothing is saved when event has no content."""
agent = LlmAgent(name="test_agent", output_key="result")
event = create_test_event(author="test_agent", content_text="")
agent._LlmAgent__maybe_save_output_to_state(event)
# Should not save empty content
assert len(event.actions.state_delta) == 0
def test_maybe_save_output_to_state_with_output_schema(self):
"""Test that output is processed with schema when output_schema is set."""
agent = LlmAgent(
name="test_agent", output_key="result", output_schema=MockOutputSchema
)
# Create event with JSON content
json_content = '{"message": "Hello", "confidence": 0.95}'
event = create_test_event(author="test_agent", content_text=json_content)
agent._LlmAgent__maybe_save_output_to_state(event)
# Should save parsed and validated output
expected_output = {"message": "Hello", "confidence": 0.95}
assert event.actions.state_delta["result"] == expected_output
def test_maybe_save_output_to_state_multiple_parts(self):
"""Test that multiple text parts are concatenated."""
agent = LlmAgent(name="test_agent", output_key="result")
# Create event with multiple text parts
parts = [
types.Part.from_text(text="Hello "),
types.Part.from_text(text="world"),
types.Part.from_text(text="!"),
]
content = types.Content(role="model", parts=parts)
event = Event(
invocation_id="test_invocation",
author="test_agent",
content=content,
actions=EventActions(),
)
agent._LlmAgent__maybe_save_output_to_state(event)
# Should concatenate all text parts
assert event.actions.state_delta["result"] == "Hello world!"
def test_maybe_save_output_to_state_agent_transfer_scenario(self, caplog):
"""Test realistic agent transfer scenario."""
# Scenario: Agent A transfers to Agent B, Agent B produces output
# Agent A should not save Agent B's output
# Set the LlmAgent logger to DEBUG level
llm_agent_logger = logging.getLogger(
"google_adk.google.adk.agents.llm_agent"
)
original_level = llm_agent_logger.level
llm_agent_logger.setLevel(logging.DEBUG)
try:
agent_a = LlmAgent(name="support_agent", output_key="support_result")
agent_b_event = create_test_event(
author="billing_agent", content_text="Your bill is $100"
)
with caplog.at_level("DEBUG"):
agent_a._LlmAgent__maybe_save_output_to_state(agent_b_event)
# Agent A should not save Agent B's output
assert len(agent_b_event.actions.state_delta) == 0
assert (
"Skipping output save for agent support_agent: event authored by"
" billing_agent"
in caplog.text
)
finally:
# Restore original logger level
llm_agent_logger.setLevel(original_level)
def test_maybe_save_output_to_state_case_sensitive_names(self, caplog):
"""Test that agent name comparison is case-sensitive."""
# Set the LlmAgent logger to DEBUG level
llm_agent_logger = logging.getLogger(
"google_adk.google.adk.agents.llm_agent"
)
original_level = llm_agent_logger.level
llm_agent_logger.setLevel(logging.DEBUG)
try:
agent = LlmAgent(name="TestAgent", output_key="result")
event = create_test_event(
author="testagent", content_text="Test response"
)
with caplog.at_level("DEBUG"):
agent._LlmAgent__maybe_save_output_to_state(event)
# Should not save due to case mismatch
assert len(event.actions.state_delta) == 0
assert (
"Skipping output save for agent TestAgent: event authored by"
" testagent"
in caplog.text
)
finally:
# Restore original logger level
llm_agent_logger.setLevel(original_level)
@patch("google.adk.agents.llm_agent.logger")
def test_maybe_save_output_to_state_logging(self, mock_logger):
"""Test that debug logging works correctly."""
agent = LlmAgent(name="agent1", output_key="result")
event = create_test_event(author="agent2", content_text="Test response")
agent._LlmAgent__maybe_save_output_to_state(event)
# Should call logger.debug with correct parameters
mock_logger.debug.assert_called_once_with(
"Skipping output save for agent %s: event authored by %s",
"agent1",
"agent2",
)
@pytest.mark.parametrize("empty_content", ["", " ", "\n"])
def test_maybe_save_output_to_state_handles_empty_final_chunk_with_schema(
self, empty_content
):
"""Tests that the agent correctly handles an empty final streaming chunk
when an output_schema is specified, preventing a crash.
"""
# ARRANGE: Create an agent that expects a JSON output matching a schema.
agent = LlmAgent(
name="test_agent", output_key="result", output_schema=MockOutputSchema
)
# ARRANGE: Create a final event with empty or whitespace-only content.
# This simulates the final, empty chunk from a model's streaming response.
event = create_test_event(
author="test_agent", content_text=empty_content, is_final=True
)
# ACT: Call the method. The test's primary goal is to ensure this
# does NOT raise a pydantic.ValidationError, which it would have before the fix.
try:
agent._LlmAgent__maybe_save_output_to_state(event)
except Exception as e:
pytest.fail(f"The method unexpectedly raised an exception: {e}")
# ASSERT: Because the method should return early, the state_delta
# should remain empty.
assert len(event.actions.state_delta) == 0
@pytest.mark.asyncio
async def test_output_key_saved_when_before_agent_callback_short_circuits(
self,
):
"""Test that output_key is written to session state when
before_agent_callback short-circuits the agent."""
def cache_callback(callback_context: CallbackContext) -> types.Content:
return types.Content(parts=[types.Part.from_text(text="cached answer")])
agent = LlmAgent(
name="test_agent",
output_key="result",
before_agent_callback=cache_callback,
)
runner = testing_utils.InMemoryRunner(agent)
await runner.run_async("hello")
assert runner.session.state.get("result") == "cached answer"
def test_maybe_save_output_to_state_skips_function_response_only_event(self):
"""Test that state_delta set by callback is not overwritten when event
only has function_response parts and no text.
"""
agent = LlmAgent(name="test_agent", output_key="result")
# Simulate a function_response-only event (no text parts)
parts = [
types.Part(
function_response=types.FunctionResponse(
name="my_tool",
response={"status": "success", "data": [1, 2, 3]},
)
)
]
content = types.Content(role="user", parts=parts)
event = Event(
invocation_id="test_invocation",
author="test_agent",
content=content,
actions=EventActions(
skip_summarization=True,
state_delta={"result": [1, 2, 3]},
),
)
agent._LlmAgent__maybe_save_output_to_state(event)
# The callback-set value should be preserved, not overwritten with ""
assert event.actions.state_delta["result"] == [1, 2, 3]
def test_maybe_save_output_to_state_saves_empty_string_when_text_is_empty(
self,
):
"""Test that output is saved as empty string when part.text is explicitly empty."""
agent = LlmAgent(name="test_agent", output_key="result")
# Explicitly construct a part with empty string text
parts = [types.Part(text="")]
content = types.Content(role="model", parts=parts)
event = Event(
invocation_id="test_invocation",
author="test_agent",
content=content,
actions=EventActions(),
)
agent._LlmAgent__maybe_save_output_to_state(event)
# Assert key exists and value is empty string
assert "result" in event.actions.state_delta
assert not event.actions.state_delta["result"]
@@ -0,0 +1,49 @@
# 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 google.adk.agents import LlmAgent
def test_single_turn_managed_agent_is_wrapped_as_tool():
from google.adk.agents import ManagedAgent
from google.adk.tools.agent_tool import _SingleTurnAgentTool
managed = ManagedAgent(name='m', agent_id='a', mode='single_turn')
coordinator = LlmAgent(name='c', sub_agents=[managed])
assert any(
isinstance(t, _SingleTurnAgentTool) and t.agent is managed
for t in coordinator.tools
)
def test_managed_agent_without_mode_is_not_wrapped():
from google.adk.agents import ManagedAgent
from google.adk.tools.agent_tool import _SingleTurnAgentTool
managed = ManagedAgent(name='m', agent_id='a') # mode defaults to None
coordinator = LlmAgent(name='c', sub_agents=[managed])
assert not any(isinstance(t, _SingleTurnAgentTool) for t in coordinator.tools)
def test_single_turn_managed_agent_excluded_from_transfer_targets():
from google.adk.agents import ManagedAgent
from google.adk.flows.llm_flows.agent_transfer import _get_transfer_targets
managed = ManagedAgent(name='m', agent_id='a', mode='single_turn')
coordinator = LlmAgent(name='c', sub_agents=[managed])
targets = _get_transfer_targets(coordinator)
assert managed not in targets
@@ -0,0 +1,120 @@
# 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.
"""Integration tests for LlmAgent output_key under streaming with tool calls."""
from __future__ import annotations
from typing import AsyncGenerator
from unittest import mock
from google.adk.agents.invocation_context import InvocationContext
from google.adk.agents.llm_agent import LlmAgent
from google.adk.agents.run_config import RunConfig
from google.adk.events.event import Event
from google.adk.events.event_actions import EventActions
from google.adk.sessions.in_memory_session_service import InMemorySessionService
from google.genai import types
import pytest
def _text(text: str) -> types.Part:
return types.Part.from_text(text=text)
def _call(name: str) -> types.Part:
return types.Part(function_call=types.FunctionCall(name=name, args={}))
def _response(name: str) -> types.Part:
return types.Part(
function_response=types.FunctionResponse(name=name, response={"ok": True})
)
def _event(
parts: list[types.Part], *, role: str = "model", partial: bool = False
) -> Event:
return Event(
invocation_id="inv",
author="agent",
content=types.Content(role=role, parts=parts),
actions=EventActions(),
partial=partial,
)
@pytest.mark.asyncio
async def test_run_async_accumulates_text_around_tool_calls():
"""Regression test for issue #5590.
Under StreamingMode.SSE with tools, an LlmAgent emits text in several
non-partial events: some carry text only, others carry text alongside a
function_call. Event.is_final_response() returns False for any event
with a function_call or function_response part, so the text on those
events was historically dropped from output_key — only the final
tool-free event's text was saved. Reporters measured ~60-70% loss.
Drive _run_async_impl with a stubbed _llm_flow that yields the canned
event sequence the streaming flow produces, merge each event's
state_delta into session state via session_service.append_event, and
assert that the user-visible session.state[output_key] contains every
non-partial text segment the agent emitted, in order.
"""
canned = [
_event([_text("Intro one. ")], partial=True),
_event([_text("Intro two.")], partial=True),
_event([_text("Intro one. Intro two."), _call("t")]),
_event([_response("t")], role="user"),
_event([_text("Progress.")], partial=True),
_event([_text("Progress."), _call("t")]),
_event([_response("t")], role="user"),
_event([_text("Conclusion one. ")], partial=True),
_event([_text("Conclusion two.")], partial=True),
_event([_text("Conclusion one. Conclusion two.")]),
]
class _FakeFlow:
async def run_async(
self, _ctx: InvocationContext
) -> AsyncGenerator[Event, None]:
for event in canned:
yield event
agent = LlmAgent(name="agent", output_key="final_output")
session_service = InMemorySessionService()
session = await session_service.create_session(
app_name="t", user_id="u", session_id="s"
)
ctx = InvocationContext(
invocation_id="inv",
agent=agent,
session=session,
session_service=session_service,
run_config=RunConfig(),
)
with mock.patch.object(
type(agent),
"_llm_flow",
new_callable=mock.PropertyMock,
return_value=_FakeFlow(),
):
async for event in agent._run_async_impl(ctx):
await session_service.append_event(session, event)
assert session.state["final_output"] == (
"Intro one. Intro two.Progress.Conclusion one. Conclusion two."
)
+292
View File
@@ -0,0 +1,292 @@
# 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.
"""Testings for the SequentialAgent."""
from typing import AsyncGenerator
from unittest.mock import patch
from google.adk.agents.base_agent import BaseAgent
from google.adk.agents.invocation_context import InvocationContext
from google.adk.agents.loop_agent import LoopAgent
from google.adk.agents.loop_agent import LoopAgentState
from google.adk.apps import ResumabilityConfig
from google.adk.events.event import Event
from google.adk.events.event_actions import EventActions
from google.adk.sessions.in_memory_session_service import InMemorySessionService
from google.genai import types
import pytest
from typing_extensions import override
from .. import testing_utils
END_OF_AGENT = testing_utils.END_OF_AGENT
class _TestingAgent(BaseAgent):
@override
async def _run_async_impl(
self, ctx: InvocationContext
) -> AsyncGenerator[Event, None]:
yield Event(
author=self.name,
invocation_id=ctx.invocation_id,
content=types.Content(
parts=[types.Part(text=f'Hello, async {self.name}!')]
),
)
@override
async def _run_live_impl(
self, ctx: InvocationContext
) -> AsyncGenerator[Event, None]:
yield Event(
author=self.name,
invocation_id=ctx.invocation_id,
content=types.Content(
parts=[types.Part(text=f'Hello, live {self.name}!')]
),
)
class _TestingAgentWithEscalateAction(BaseAgent):
@override
async def _run_async_impl(
self, ctx: InvocationContext
) -> AsyncGenerator[Event, None]:
yield Event(
author=self.name,
invocation_id=ctx.invocation_id,
content=types.Content(
parts=[types.Part(text=f'Hello, async {self.name}!')]
),
actions=EventActions(escalate=True),
)
yield Event(
author=self.name,
invocation_id=ctx.invocation_id,
content=types.Content(
parts=[types.Part(text='I have done my job after escalation!!')]
),
)
async def _create_parent_invocation_context(
test_name: str, agent: BaseAgent, resumable: bool = False
) -> InvocationContext:
session_service = InMemorySessionService()
session = await session_service.create_session(
app_name='test_app', user_id='test_user'
)
return InvocationContext(
invocation_id=f'{test_name}_invocation_id',
agent=agent,
session=session,
session_service=session_service,
resumability_config=ResumabilityConfig(is_resumable=resumable),
)
@pytest.mark.asyncio
@pytest.mark.parametrize('resumable', [True, False])
async def test_run_async(request: pytest.FixtureRequest, resumable: bool):
agent = _TestingAgent(name=f'{request.function.__name__}_test_agent')
loop_agent = LoopAgent(
name=f'{request.function.__name__}_test_loop_agent',
max_iterations=2,
sub_agents=[
agent,
],
)
parent_ctx = await _create_parent_invocation_context(
request.function.__name__, loop_agent, resumable=resumable
)
events = [e async for e in loop_agent.run_async(parent_ctx)]
simplified_events = testing_utils.simplify_resumable_app_events(events)
if resumable:
expected_events = [
(
loop_agent.name,
{'current_sub_agent': agent.name, 'times_looped': 0},
),
(agent.name, f'Hello, async {agent.name}!'),
(
loop_agent.name,
{'current_sub_agent': agent.name, 'times_looped': 1},
),
(agent.name, f'Hello, async {agent.name}!'),
(loop_agent.name, END_OF_AGENT),
]
else:
expected_events = [
(agent.name, f'Hello, async {agent.name}!'),
(agent.name, f'Hello, async {agent.name}!'),
]
assert simplified_events == expected_events
@pytest.mark.asyncio
async def test_resume_async(request: pytest.FixtureRequest):
agent_1 = _TestingAgent(name=f'{request.function.__name__}_test_agent_1')
agent_2 = _TestingAgent(name=f'{request.function.__name__}_test_agent_2')
loop_agent = LoopAgent(
name=f'{request.function.__name__}_test_loop_agent',
max_iterations=2,
sub_agents=[
agent_1,
agent_2,
],
)
parent_ctx = await _create_parent_invocation_context(
request.function.__name__, loop_agent, resumable=True
)
parent_ctx.agent_states[loop_agent.name] = LoopAgentState(
current_sub_agent=agent_2.name, times_looped=1
).model_dump(mode='json')
events = [e async for e in loop_agent.run_async(parent_ctx)]
simplified_events = testing_utils.simplify_resumable_app_events(events)
expected_events = [
(agent_2.name, f'Hello, async {agent_2.name}!'),
(loop_agent.name, END_OF_AGENT),
]
assert simplified_events == expected_events
@pytest.mark.asyncio
async def test_run_async_skip_if_no_sub_agent(request: pytest.FixtureRequest):
loop_agent = LoopAgent(
name=f'{request.function.__name__}_test_loop_agent',
max_iterations=2,
sub_agents=[],
)
parent_ctx = await _create_parent_invocation_context(
request.function.__name__, loop_agent
)
events = [e async for e in loop_agent.run_async(parent_ctx)]
assert not events
@pytest.mark.asyncio
@pytest.mark.parametrize('resumable', [True, False])
async def test_run_async_with_escalate_action(
request: pytest.FixtureRequest, resumable: bool
):
non_escalating_agent = _TestingAgent(
name=f'{request.function.__name__}_test_non_escalating_agent'
)
escalating_agent = _TestingAgentWithEscalateAction(
name=f'{request.function.__name__}_test_escalating_agent'
)
ignored_agent = _TestingAgent(
name=f'{request.function.__name__}_test_ignored_agent'
)
loop_agent = LoopAgent(
name=f'{request.function.__name__}_test_loop_agent',
sub_agents=[non_escalating_agent, escalating_agent, ignored_agent],
)
parent_ctx = await _create_parent_invocation_context(
request.function.__name__, loop_agent, resumable=resumable
)
events = [e async for e in loop_agent.run_async(parent_ctx)]
simplified_events = testing_utils.simplify_resumable_app_events(events)
if resumable:
expected_events = [
(
loop_agent.name,
{
'current_sub_agent': non_escalating_agent.name,
'times_looped': 0,
},
),
(
non_escalating_agent.name,
f'Hello, async {non_escalating_agent.name}!',
),
(
loop_agent.name,
{'current_sub_agent': escalating_agent.name, 'times_looped': 0},
),
(
escalating_agent.name,
f'Hello, async {escalating_agent.name}!',
),
(
escalating_agent.name,
'I have done my job after escalation!!',
),
(loop_agent.name, END_OF_AGENT),
]
else:
expected_events = [
(
non_escalating_agent.name,
f'Hello, async {non_escalating_agent.name}!',
),
(
escalating_agent.name,
f'Hello, async {escalating_agent.name}!',
),
(
escalating_agent.name,
'I have done my job after escalation!!',
),
]
assert simplified_events == expected_events
@pytest.mark.asyncio
async def test_run_async_with_pause_preserves_sub_agent_state(
request: pytest.FixtureRequest,
):
"""Test that the sub-agent state is preserved when the loop agent pauses."""
agent = _TestingAgent(name=f'{request.function.__name__}_test_agent')
loop_agent = LoopAgent(
name=f'{request.function.__name__}_test_loop_agent',
max_iterations=2,
sub_agents=[agent],
)
parent_ctx = await _create_parent_invocation_context(
request.function.__name__, loop_agent, resumable=True
)
# Set some dummy state for the sub-agent
parent_ctx.agent_states[agent.name] = {'some_key': 'some_value'}
# Mock should_pause_invocation to return True for the agent's event
def mock_should_pause(event):
return event.author == agent.name
with patch.object(
InvocationContext,
'should_pause_invocation',
side_effect=mock_should_pause,
):
async for _ in loop_agent.run_async(parent_ctx):
pass # Consume the async generator
# Verify that the sub-agent state was NOT reset
assert agent.name in parent_ctx.agent_states
assert parent_ctx.agent_states[agent.name] == {'some_key': 'some_value'}
def test_deprecation_mentions_sub_agent_limitation():
with pytest.warns(DeprecationWarning, match='sub-agent'):
LoopAgent(name='deprecated_loop', sub_agents=[])
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,191 @@
# 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.
"""Unit tests for McpInstructionProvider."""
from unittest.mock import AsyncMock
from unittest.mock import MagicMock
from unittest.mock import patch
from google.adk.agents.mcp_instruction_provider import McpInstructionProvider
from google.adk.agents.readonly_context import ReadonlyContext
import pytest
class TestMcpInstructionProvider:
"""Unit tests for McpInstructionProvider."""
def setup_method(self):
"""Sets up the test environment."""
self.connection_params = {"host": "localhost", "port": 8000}
self.prompt_name = "test_prompt"
self.mock_mcp_session_manager_cls = patch(
"google.adk.agents.mcp_instruction_provider.MCPSessionManager"
).start()
self.mock_mcp_session_manager = (
self.mock_mcp_session_manager_cls.return_value
)
self.mock_session = MagicMock()
self.mock_session.list_prompts = AsyncMock()
self.mock_session.get_prompt = AsyncMock()
self.mock_mcp_session_manager.create_session = AsyncMock(
return_value=self.mock_session
)
self.provider = McpInstructionProvider(
self.connection_params, self.prompt_name
)
@pytest.mark.asyncio
async def test_call_success_no_args(self):
"""Tests __call__ with a prompt that has no arguments."""
mock_prompt = MagicMock()
mock_prompt.name = self.prompt_name
mock_prompt.arguments = None
self.mock_session.list_prompts.return_value = MagicMock(
prompts=[mock_prompt]
)
mock_msg1 = MagicMock()
mock_msg1.content.type = "text"
mock_msg1.content.text = "instruction part 1. "
mock_msg2 = MagicMock()
mock_msg2.content.type = "text"
mock_msg2.content.text = "instruction part 2"
self.mock_session.get_prompt.return_value = MagicMock(
messages=[mock_msg1, mock_msg2]
)
mock_invocation_context = MagicMock()
mock_invocation_context.session.state = {}
context = ReadonlyContext(mock_invocation_context)
# Call
instruction = await self.provider(context)
# Assert
assert instruction == "instruction part 1. instruction part 2"
self.mock_session.get_prompt.assert_called_once_with(
self.prompt_name, arguments={}
)
@pytest.mark.asyncio
async def test_call_success_with_args(self):
"""Tests __call__ with a prompt that has arguments."""
mock_arg1 = MagicMock()
mock_arg1.name = "arg1"
mock_prompt = MagicMock()
mock_prompt.name = self.prompt_name
mock_prompt.arguments = [mock_arg1]
self.mock_session.list_prompts.return_value = MagicMock(
prompts=[mock_prompt]
)
mock_msg = MagicMock()
mock_msg.content.type = "text"
mock_msg.content.text = "instruction with arg1"
self.mock_session.get_prompt.return_value = MagicMock(messages=[mock_msg])
mock_invocation_context = MagicMock()
mock_invocation_context.session.state = {"arg1": "value1", "arg2": "value2"}
context = ReadonlyContext(mock_invocation_context)
instruction = await self.provider(context)
assert instruction == "instruction with arg1"
self.mock_session.get_prompt.assert_called_once_with(
self.prompt_name, arguments={"arg1": "value1"}
)
@pytest.mark.asyncio
async def test_call_prompt_not_found_in_list_prompts(self):
"""Tests __call__ when list_prompts doesn't return the prompt."""
self.mock_session.list_prompts.return_value = MagicMock(prompts=[])
mock_msg = MagicMock()
mock_msg.content.type = "text"
mock_msg.content.text = "instruction"
self.mock_session.get_prompt.return_value = MagicMock(messages=[mock_msg])
mock_invocation_context = MagicMock()
mock_invocation_context.session.state = {"arg1": "value1"}
context = ReadonlyContext(mock_invocation_context)
instruction = await self.provider(context)
assert instruction == "instruction"
self.mock_session.get_prompt.assert_called_once_with(
self.prompt_name, arguments={}
)
@pytest.mark.asyncio
async def test_call_get_prompt_returns_no_messages(self):
"""Tests __call__ when get_prompt returns no messages."""
# Setup mocks
self.mock_session.list_prompts.return_value = MagicMock(prompts=[])
self.mock_session.get_prompt.return_value = MagicMock(messages=[])
mock_invocation_context = MagicMock()
mock_invocation_context.session.state = {}
context = ReadonlyContext(mock_invocation_context)
# Call and assert
with pytest.raises(
ValueError, match="Failed to load MCP prompt 'test_prompt'."
):
await self.provider(context)
# Assert
self.mock_session.get_prompt.assert_called_once_with(
self.prompt_name, arguments={}
)
@pytest.mark.asyncio
async def test_call_ignore_non_text_messages(self):
"""Tests __call__ ignores non-text messages."""
# Setup mocks
mock_prompt = MagicMock()
mock_prompt.name = self.prompt_name
mock_prompt.arguments = None
self.mock_session.list_prompts.return_value = MagicMock(
prompts=[mock_prompt]
)
mock_msg1 = MagicMock()
mock_msg1.content.type = "text"
mock_msg1.content.text = "instruction part 1. "
mock_msg2 = MagicMock()
mock_msg2.content.type = "image"
mock_msg2.content.text = "ignored"
mock_msg3 = MagicMock()
mock_msg3.content.type = "text"
mock_msg3.content.text = "instruction part 2"
self.mock_session.get_prompt.return_value = MagicMock(
messages=[mock_msg1, mock_msg2, mock_msg3]
)
mock_invocation_context = MagicMock()
mock_invocation_context.session.state = {}
context = ReadonlyContext(mock_invocation_context)
# Call
instruction = await self.provider(context)
# Assert
assert instruction == "instruction part 1. instruction part 2"
self.mock_session.get_prompt.assert_called_once_with(
self.prompt_name, arguments={}
)
@@ -0,0 +1,250 @@
# 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 enum import Enum
from functools import partial
from typing import Any
from typing import List
from typing import Optional
from unittest import mock
from google.adk.agents.callback_context import CallbackContext
from google.adk.agents.llm_agent import Agent
from google.adk.models.llm_request import LlmRequest
from google.adk.models.llm_response import LlmResponse
from google.genai import types
from pydantic import BaseModel
import pytest
from .. import testing_utils
class CallbackType(Enum):
SYNC = 1
ASYNC = 2
async def mock_async_before_cb_side_effect(
callback_context: CallbackContext,
llm_request: LlmRequest,
ret_value=None,
):
if ret_value:
return LlmResponse(
content=testing_utils.ModelContent(
[types.Part.from_text(text=ret_value)]
)
)
return None
def mock_sync_before_cb_side_effect(
callback_context: CallbackContext,
llm_request: LlmRequest,
ret_value=None,
):
if ret_value:
return LlmResponse(
content=testing_utils.ModelContent(
[types.Part.from_text(text=ret_value)]
)
)
return None
async def mock_async_after_cb_side_effect(
callback_context: CallbackContext,
llm_response: LlmResponse,
ret_value=None,
):
if ret_value:
return LlmResponse(
content=testing_utils.ModelContent(
[types.Part.from_text(text=ret_value)]
)
)
return None
def mock_sync_after_cb_side_effect(
callback_context: CallbackContext,
llm_response: LlmResponse,
ret_value=None,
):
if ret_value:
return LlmResponse(
content=testing_utils.ModelContent(
[types.Part.from_text(text=ret_value)]
)
)
return None
CALLBACK_PARAMS = [
pytest.param(
[
(None, CallbackType.SYNC),
("callback_2_response", CallbackType.ASYNC),
("callback_3_response", CallbackType.SYNC),
(None, CallbackType.ASYNC),
],
"callback_2_response",
[1, 1, 0, 0],
id="middle_async_callback_returns",
),
pytest.param(
[
(None, CallbackType.SYNC),
(None, CallbackType.ASYNC),
(None, CallbackType.SYNC),
(None, CallbackType.ASYNC),
],
"model_response",
[1, 1, 1, 1],
id="all_callbacks_return_none",
),
pytest.param(
[
("callback_1_response", CallbackType.SYNC),
("callback_2_response", CallbackType.ASYNC),
],
"callback_1_response",
[1, 0],
id="first_sync_callback_returns",
),
]
@pytest.mark.parametrize(
"callbacks, expected_response, expected_calls",
CALLBACK_PARAMS,
)
@pytest.mark.asyncio
async def test_before_model_callbacks_chain(
callbacks: List[tuple[str, int]],
expected_response: str,
expected_calls: List[int],
):
responses = ["model_response"]
mock_model = testing_utils.MockModel.create(responses=responses)
mock_cbs = []
for response, callback_type in callbacks:
if callback_type == CallbackType.ASYNC:
mock_cb = mock.AsyncMock(
side_effect=partial(
mock_async_before_cb_side_effect, ret_value=response
)
)
else:
mock_cb = mock.Mock(
side_effect=partial(
mock_sync_before_cb_side_effect, ret_value=response
)
)
mock_cbs.append(mock_cb)
# Create agent with multiple callbacks
agent = Agent(
name="root_agent",
model=mock_model,
before_model_callback=[mock_cb for mock_cb in mock_cbs],
)
runner = testing_utils.TestInMemoryRunner(agent)
result = await runner.run_async_with_new_session("test")
assert testing_utils.simplify_events(result) == [
("root_agent", expected_response),
]
# Assert that the callbacks were called the expected number of times
for i, mock_cb in enumerate(mock_cbs):
expected_calls_count = expected_calls[i]
if expected_calls_count == 1:
if isinstance(mock_cb, mock.AsyncMock):
mock_cb.assert_awaited_once()
else:
mock_cb.assert_called_once()
elif expected_calls_count == 0:
if isinstance(mock_cb, mock.AsyncMock):
mock_cb.assert_not_awaited()
else:
mock_cb.assert_not_called()
else:
if isinstance(mock_cb, mock.AsyncMock):
mock_cb.assert_awaited(expected_calls_count)
else:
mock_cb.assert_called(expected_calls_count)
@pytest.mark.parametrize(
"callbacks, expected_response, expected_calls",
CALLBACK_PARAMS,
)
@pytest.mark.asyncio
async def test_after_model_callbacks_chain(
callbacks: List[tuple[str, int]],
expected_response: str,
expected_calls: List[int],
):
responses = ["model_response"]
mock_model = testing_utils.MockModel.create(responses=responses)
mock_cbs = []
for response, callback_type in callbacks:
if callback_type == CallbackType.ASYNC:
mock_cb = mock.AsyncMock(
side_effect=partial(
mock_async_after_cb_side_effect, ret_value=response
)
)
else:
mock_cb = mock.Mock(
side_effect=partial(
mock_sync_after_cb_side_effect, ret_value=response
)
)
mock_cbs.append(mock_cb)
# Create agent with multiple callbacks
agent = Agent(
name="root_agent",
model=mock_model,
after_model_callback=[mock_cb for mock_cb in mock_cbs],
)
runner = testing_utils.TestInMemoryRunner(agent)
result = await runner.run_async_with_new_session("test")
assert testing_utils.simplify_events(result) == [
("root_agent", expected_response),
]
# Assert that the callbacks were called the expected number of times
for i, mock_cb in enumerate(mock_cbs):
expected_calls_count = expected_calls[i]
if expected_calls_count == 1:
if isinstance(mock_cb, mock.AsyncMock):
mock_cb.assert_awaited_once()
else:
mock_cb.assert_called_once()
elif expected_calls_count == 0:
if isinstance(mock_cb, mock.AsyncMock):
mock_cb.assert_not_awaited()
else:
mock_cb.assert_not_called()
else:
if isinstance(mock_cb, mock.AsyncMock):
mock_cb.assert_awaited(expected_calls_count)
else:
mock_cb.assert_called(expected_calls_count)
@@ -0,0 +1,180 @@
# 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 LlmAgent output_key visibility in callbacks."""
from google.adk.agents.callback_context import CallbackContext
from google.adk.agents.live_request_queue import LiveRequestQueue
from google.adk.agents.llm_agent import LlmAgent
from google.adk.agents.sequential_agent import SequentialAgent
from google.adk.events.event import Event
from google.adk.flows.llm_flows.auto_flow import AutoFlow
from google.genai import types
import pytest
from pytest_mock import MockerFixture
from .. import testing_utils
# Standard MockModel will be used instead of SafeMockModel
@pytest.mark.asyncio
async def test_output_key_visibility_in_after_agent_callback():
"""Test that output_key state delta is visible in after_agent_callback."""
mock_response = "Hello! How can I help you?"
mock_model = testing_utils.MockModel.create(responses=[mock_response])
callback_called = False
captured_state_value = None
captured_session_state_value = None
async def check_output_key(callback_context: CallbackContext):
nonlocal callback_called, captured_state_value, captured_session_state_value
callback_called = True
captured_state_value = callback_context.state.get("result", "NOT_FOUND")
captured_session_state_value = callback_context.session.state.get(
"result", "NOT_IN_RAW"
)
agent = LlmAgent(
name="my_agent",
model=mock_model,
instruction="Reply with a short greeting.",
output_key="result",
after_agent_callback=check_output_key,
)
runner = testing_utils.InMemoryRunner(root_agent=agent)
events = await runner.run_async(new_message="hello")
assert callback_called, "Callback was not called"
assert (
captured_state_value == mock_response
), f"Expected {mock_response}, got {captured_state_value}"
assert (
captured_session_state_value == mock_response
), f"Expected {mock_response}, got {captured_session_state_value}"
@pytest.mark.asyncio
async def test_output_key_visibility_in_run_live(mocker: MockerFixture):
"""Test that output_key state delta is visible in after_agent_callback in run_live."""
mock_response = "Hello! How can I help you?"
mock_model = testing_utils.MockModel.create(responses=[mock_response])
callback_called = False
captured_state_value = None
captured_session_state_value = None
async def check_output_key(callback_context: CallbackContext):
nonlocal callback_called, captured_state_value, captured_session_state_value
callback_called = True
captured_state_value = callback_context.state.get("result", "NOT_FOUND")
captured_session_state_value = callback_context.session.state.get(
"result", "NOT_IN_RAW"
)
agent = LlmAgent(
name="my_agent",
model=mock_model,
instruction="Reply with a short greeting.",
output_key="result",
after_agent_callback=check_output_key,
)
async def mock_auto_flow_run_live(self, ctx):
yield Event(
id=Event.new_id(),
invocation_id=ctx.invocation_id,
author=ctx.agent.name,
content=types.Content(parts=[types.Part(text=mock_response)]),
)
mocker.patch.object(AutoFlow, "run_live", mock_auto_flow_run_live)
runner = testing_utils.InMemoryRunner(root_agent=agent)
live_queue = LiveRequestQueue()
agen = runner.runner.run_live(
user_id="test_user",
session_id=runner.session.id,
live_request_queue=live_queue,
)
# Send a message to trigger the agent
live_queue.send_content(
types.Content(role="user", parts=[types.Part(text="hello")])
)
live_queue.close()
async for event in agen:
pass
assert callback_called, "Callback was not called"
assert (
captured_state_value == mock_response
), f"Expected {mock_response}, got {captured_state_value}"
assert (
captured_session_state_value == mock_response
), f"Expected {mock_response}, got {captured_session_state_value}"
@pytest.mark.asyncio
async def test_output_key_visibility_in_sequential_agent():
"""Test that output_key state delta is visible in next agent's before_agent_callback."""
mock_response = "Hello from agent 1!"
mock_model = testing_utils.MockModel.create(
responses=[mock_response, "Hello from agent 2!"]
)
callback_called = False
captured_session_state_value = None
async def check_before_agent(callback_context: CallbackContext):
nonlocal callback_called, captured_session_state_value
callback_called = True
captured_session_state_value = callback_context.session.state.get(
"result", "NOT_FOUND"
)
agent_1 = LlmAgent(
name="agent_1",
model=mock_model,
instruction="Reply with a short greeting.",
output_key="result",
)
agent_2 = LlmAgent(
name="agent_2",
model=mock_model,
instruction="Reply with a short greeting.",
before_agent_callback=check_before_agent,
)
sequential_agent = SequentialAgent(
name="seq_agent",
sub_agents=[agent_1, agent_2],
)
runner = testing_utils.InMemoryRunner(root_agent=sequential_agent)
events = await runner.run_async(new_message="hello")
assert callback_called, "Callback was not called"
assert (
captured_session_state_value == mock_response
), f"Expected {mock_response}, got {captured_session_state_value}"
@@ -0,0 +1,416 @@
# 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 the ParallelAgent."""
import asyncio
from typing import AsyncGenerator
from google.adk.agents.base_agent import BaseAgent
from google.adk.agents.base_agent import BaseAgentState
from google.adk.agents.invocation_context import InvocationContext
from google.adk.agents.parallel_agent import _merge_agent_run_pre_3_11
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 ResumabilityConfig
from google.adk.events.event import Event
from google.adk.sessions.in_memory_session_service import InMemorySessionService
from google.genai import types
import pytest
from typing_extensions import override
class _TestingAgent(BaseAgent):
delay: float = 0
"""The delay before the agent generates an event."""
def event(self, ctx: InvocationContext):
return Event(
author=self.name,
branch=ctx.branch,
invocation_id=ctx.invocation_id,
content=types.Content(
parts=[types.Part(text=f'Hello, async {self.name}!')]
),
)
@override
async def _run_async_impl(
self, ctx: InvocationContext
) -> AsyncGenerator[Event, None]:
await asyncio.sleep(self.delay)
yield self.event(ctx)
if ctx.is_resumable:
ctx.set_agent_state(self.name, end_of_agent=True)
async def _create_parent_invocation_context(
test_name: str, agent: BaseAgent, is_resumable: bool = False
) -> InvocationContext:
session_service = InMemorySessionService()
session = await session_service.create_session(
app_name='test_app', user_id='test_user'
)
return InvocationContext(
invocation_id=f'{test_name}_invocation_id',
agent=agent,
session=session,
session_service=session_service,
resumability_config=ResumabilityConfig(is_resumable=is_resumable),
)
@pytest.mark.asyncio
@pytest.mark.parametrize('is_resumable', [True, False])
async def test_run_async(request: pytest.FixtureRequest, is_resumable: bool):
agent1 = _TestingAgent(
name=f'{request.function.__name__}_test_agent_1',
delay=0.5,
)
agent2 = _TestingAgent(name=f'{request.function.__name__}_test_agent_2')
parallel_agent = ParallelAgent(
name=f'{request.function.__name__}_test_parallel_agent',
sub_agents=[
agent1,
agent2,
],
)
parent_ctx = await _create_parent_invocation_context(
request.function.__name__, parallel_agent, is_resumable=is_resumable
)
events = [e async for e in parallel_agent.run_async(parent_ctx)]
if is_resumable:
assert len(events) == 4
assert events[0].author == parallel_agent.name
assert not events[0].actions.end_of_agent
# agent2 generates an event first, then agent1. Because they run in parallel
# and agent1 has a delay.
assert events[1].author == agent2.name
assert events[2].author == agent1.name
assert events[1].branch == f'{parallel_agent.name}.{agent2.name}'
assert events[2].branch == f'{parallel_agent.name}.{agent1.name}'
assert events[1].content.parts[0].text == f'Hello, async {agent2.name}!'
assert events[2].content.parts[0].text == f'Hello, async {agent1.name}!'
assert events[3].author == parallel_agent.name
assert events[3].actions.end_of_agent
else:
assert len(events) == 2
assert events[0].author == agent2.name
assert events[1].author == agent1.name
assert events[0].branch == f'{parallel_agent.name}.{agent2.name}'
assert events[1].branch == f'{parallel_agent.name}.{agent1.name}'
assert events[0].content.parts[0].text == f'Hello, async {agent2.name}!'
assert events[1].content.parts[0].text == f'Hello, async {agent1.name}!'
@pytest.mark.asyncio
@pytest.mark.parametrize('is_resumable', [True, False])
async def test_run_async_branches(
request: pytest.FixtureRequest, is_resumable: bool
):
agent1 = _TestingAgent(
name=f'{request.function.__name__}_test_agent_1',
delay=0.5,
)
agent2 = _TestingAgent(name=f'{request.function.__name__}_test_agent_2')
agent3 = _TestingAgent(name=f'{request.function.__name__}_test_agent_3')
sequential_agent = SequentialAgent(
name=f'{request.function.__name__}_test_sequential_agent',
sub_agents=[agent2, agent3],
)
parallel_agent = ParallelAgent(
name=f'{request.function.__name__}_test_parallel_agent',
sub_agents=[
sequential_agent,
agent1,
],
)
parent_ctx = await _create_parent_invocation_context(
request.function.__name__, parallel_agent, is_resumable=is_resumable
)
events = [e async for e in parallel_agent.run_async(parent_ctx)]
if is_resumable:
assert len(events) == 8
# 1. parallel agent checkpoint
assert events[0].author == parallel_agent.name
assert not events[0].actions.end_of_agent
# 2. sequential agent checkpoint
assert events[1].author == sequential_agent.name
assert not events[1].actions.end_of_agent
assert events[1].actions.agent_state['current_sub_agent'] == agent2.name
assert events[1].branch == f'{parallel_agent.name}.{sequential_agent.name}'
# 3. agent 2 event
assert events[2].author == agent2.name
assert events[2].branch == f'{parallel_agent.name}.{sequential_agent.name}'
# 4. sequential agent checkpoint
assert events[3].author == sequential_agent.name
assert not events[3].actions.end_of_agent
assert events[3].actions.agent_state['current_sub_agent'] == agent3.name
assert events[3].branch == f'{parallel_agent.name}.{sequential_agent.name}'
# 5. agent 3 event
assert events[4].author == agent3.name
assert events[4].branch == f'{parallel_agent.name}.{sequential_agent.name}'
# 6. sequential agent checkpoint (end)
assert events[5].author == sequential_agent.name
assert events[5].actions.end_of_agent
assert events[5].branch == f'{parallel_agent.name}.{sequential_agent.name}'
# Descendants of the same sub-agent should have the same branch.
assert events[1].branch == events[2].branch
assert events[2].branch == events[3].branch
assert events[3].branch == events[4].branch
assert events[4].branch == events[5].branch
# 7. agent 1 event
assert events[6].author == agent1.name
assert events[6].branch == f'{parallel_agent.name}.{agent1.name}'
# Sub-agents should have different branches.
assert events[6].branch != events[1].branch
# 8. parallel agent checkpoint (end)
assert events[7].author == parallel_agent.name
assert events[7].actions.end_of_agent
else:
assert len(events) == 3
# 1. agent 2 event
assert events[0].author == agent2.name
assert events[0].branch == f'{parallel_agent.name}.{sequential_agent.name}'
# 2. agent 3 event
assert events[1].author == agent3.name
assert events[1].branch == f'{parallel_agent.name}.{sequential_agent.name}'
# 3. agent 1 event
assert events[2].author == agent1.name
assert events[2].branch == f'{parallel_agent.name}.{agent1.name}'
@pytest.mark.asyncio
async def test_resume_async_branches(request: pytest.FixtureRequest):
agent1 = _TestingAgent(
name=f'{request.function.__name__}_test_agent_1', delay=0.5
)
agent2 = _TestingAgent(name=f'{request.function.__name__}_test_agent_2')
agent3 = _TestingAgent(name=f'{request.function.__name__}_test_agent_3')
sequential_agent = SequentialAgent(
name=f'{request.function.__name__}_test_sequential_agent',
sub_agents=[agent2, agent3],
)
parallel_agent = ParallelAgent(
name=f'{request.function.__name__}_test_parallel_agent',
sub_agents=[
sequential_agent,
agent1,
],
)
parent_ctx = await _create_parent_invocation_context(
request.function.__name__, parallel_agent, is_resumable=True
)
parent_ctx.agent_states[parallel_agent.name] = BaseAgentState().model_dump(
mode='json'
)
parent_ctx.agent_states[sequential_agent.name] = SequentialAgentState(
current_sub_agent=agent3.name
).model_dump(mode='json')
events = [e async for e in parallel_agent.run_async(parent_ctx)]
assert len(events) == 4
# The sequential agent resumes from agent3.
# 1. Agent 3 event
assert events[0].author == agent3.name
assert events[0].branch == f'{parallel_agent.name}.{sequential_agent.name}'
# 2. Sequential agent checkpoint (end)
assert events[1].author == sequential_agent.name
assert events[1].actions.end_of_agent
assert events[1].branch == f'{parallel_agent.name}.{sequential_agent.name}'
# Agent 1 runs in parallel but has a delay.
# 3. Agent 1 event
assert events[2].author == agent1.name
assert events[2].branch == f'{parallel_agent.name}.{agent1.name}'
# 4. Parallel agent checkpoint (end)
assert events[3].author == parallel_agent.name
assert events[3].actions.end_of_agent
class _TestingAgentWithMultipleEvents(_TestingAgent):
"""Mock agent for testing."""
@override
async def _run_async_impl(
self, ctx: InvocationContext
) -> AsyncGenerator[Event, None]:
for _ in range(0, 3):
event = self.event(ctx)
yield event
# Check that the event was processed by the consumer.
assert event.custom_metadata is not None
assert event.custom_metadata['processed']
@pytest.mark.asyncio
async def test_generating_one_event_per_agent_at_once(
request: pytest.FixtureRequest,
):
# This test is to verify that the parallel agent won't generate more than one
# event per agent at a time.
agent1 = _TestingAgentWithMultipleEvents(
name=f'{request.function.__name__}_test_agent_1'
)
agent2 = _TestingAgentWithMultipleEvents(
name=f'{request.function.__name__}_test_agent_2'
)
parallel_agent = ParallelAgent(
name=f'{request.function.__name__}_test_parallel_agent',
sub_agents=[
agent1,
agent2,
],
)
parent_ctx = await _create_parent_invocation_context(
request.function.__name__, parallel_agent
)
agen = parallel_agent.run_async(parent_ctx)
async for event in agen:
event.custom_metadata = {'processed': True}
# Asserts on event are done in _TestingAgentWithMultipleEvents.
@pytest.mark.asyncio
async def test_run_async_skip_if_no_sub_agent(request: pytest.FixtureRequest):
parallel_agent = ParallelAgent(
name=f'{request.function.__name__}_test_parallel_agent',
sub_agents=[],
)
parent_ctx = await _create_parent_invocation_context(
request.function.__name__, parallel_agent
)
events = [e async for e in parallel_agent.run_async(parent_ctx)]
assert not events
class _TestingAgentWithException(_TestingAgent):
"""Mock agent for testing."""
@override
async def _run_async_impl(
self, ctx: InvocationContext
) -> AsyncGenerator[Event, None]:
yield self.event(ctx)
raise Exception()
class _TestingAgentInfiniteEvents(_TestingAgent):
"""Mock agent for testing."""
@override
async def _run_async_impl(
self, ctx: InvocationContext
) -> AsyncGenerator[Event, None]:
while True:
yield self.event(ctx)
@pytest.mark.asyncio
async def test_stop_agent_if_sub_agent_fails(
request: pytest.FixtureRequest,
):
# This test is to verify that the parallel agent and subagents will all stop
# processing and throw exception to top level runner in case of exception.
agent1 = _TestingAgentWithException(
name=f'{request.function.__name__}_test_agent_1'
)
agent2 = _TestingAgentInfiniteEvents(
name=f'{request.function.__name__}_test_agent_2'
)
parallel_agent = ParallelAgent(
name=f'{request.function.__name__}_test_parallel_agent',
sub_agents=[
agent1,
agent2,
],
)
parent_ctx = await _create_parent_invocation_context(
request.function.__name__, parallel_agent
)
agen = parallel_agent.run_async(parent_ctx)
# We expect to receive an exception from one of subagents.
# The exception should be propagated to root agent and other subagents.
# Otherwise we'll have an infinite loop.
with pytest.raises(Exception):
async for _ in agen:
# The infinite agent could iterate a few times depending on scheduling.
pass
async def _slow_agent_with_cleanup_delay():
"""Async generator that sleeps in its finally block to simulate cleanup."""
try:
await asyncio.sleep(10)
yield 'slow-event'
finally:
await asyncio.sleep(0.05)
async def _failing_agent():
"""Async generator that raises after a short delay."""
await asyncio.sleep(0.01)
raise ValueError('simulated sub-agent failure')
yield # pragma: no cover
@pytest.mark.asyncio
async def test_merge_agent_run_pre_3_11_no_aclose_error_on_failure():
"""Regression test for Python 3.10 RuntimeError: aclose() already running.
_merge_agent_run_pre_3_11 must await all cancelled tasks before returning so
that generators are fully released before the caller invokes aclose() on them.
"""
agent_runs = [_slow_agent_with_cleanup_delay(), _failing_agent()]
with pytest.raises(ValueError, match='simulated sub-agent failure'):
async for _ in _merge_agent_run_pre_3_11(agent_runs):
pass
# If tasks were not properly awaited, aclose() on a still-running generator
# would raise RuntimeError here.
for agen in agent_runs:
await agen.aclose()
def test_deprecation_mentions_sub_agent_limitation():
with pytest.warns(DeprecationWarning, match='sub-agent'):
ParallelAgent(name='deprecated_parallel', sub_agents=[])
@@ -0,0 +1,59 @@
# 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 types import MappingProxyType
from unittest.mock import MagicMock
from google.adk.agents.readonly_context import ReadonlyContext
import pytest
@pytest.fixture
def mock_invocation_context():
mock_context = MagicMock()
mock_context.invocation_id = "test-invocation-id"
mock_context.agent.name = "test-agent-name"
mock_context.session.state = {"key1": "value1", "key2": "value2"}
mock_context.user_id = "test-user-id"
return mock_context
def test_invocation_id(mock_invocation_context):
readonly_context = ReadonlyContext(mock_invocation_context)
assert readonly_context.invocation_id == "test-invocation-id"
def test_agent_name(mock_invocation_context):
readonly_context = ReadonlyContext(mock_invocation_context)
assert readonly_context.agent_name == "test-agent-name"
def test_agent_name_without_agent(mock_invocation_context):
mock_invocation_context.agent = None
readonly_context = ReadonlyContext(mock_invocation_context)
assert readonly_context.agent_name == "unknown"
def test_state_content(mock_invocation_context):
readonly_context = ReadonlyContext(mock_invocation_context)
state = readonly_context.state
assert isinstance(state, MappingProxyType)
assert state["key1"] == "value1"
assert state["key2"] == "value2"
def test_user_id(mock_invocation_context):
readonly_context = ReadonlyContext(mock_invocation_context)
assert readonly_context.user_id == "test-user-id"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,264 @@
# 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 resumable LlmAgent scenarios.
Verifies that the Mesh-based LlmAgent correctly resumes from various
states: after transfers, tool calls, tool responses, and with
sub-agent tool calls.
"""
import copy
from google.adk.agents.llm_agent import LlmAgent
from google.adk.apps.app import App
from google.adk.apps.app import ResumabilityConfig
from google.genai.types import Part
import pytest
from .. import testing_utils
def transfer_call_part(agent_name: str) -> Part:
return Part.from_function_call(
name="transfer_to_agent", args={"agent_name": agent_name}
)
TRANSFER_RESPONSE_PART = Part.from_function_response(
name="transfer_to_agent", response={"result": None}
)
END_OF_AGENT = testing_utils.END_OF_AGENT
def some_tool():
return {"result": "ok"}
def _behavioral_events(events):
"""Extract behavioral events (non-state) from resumable app events."""
return [
e
for e in testing_utils.simplify_resumable_app_events(
copy.deepcopy(events)
)
if not isinstance(e[1], dict)
]
@pytest.mark.asyncio
async def test_resume_from_transfer():
"""Tests that the agent resumes from the correct sub-agent after a transfer.
invocation1: root_agent transfers to sub_agent_1
invocation2: sub_agent_1 responds (resuming from the transfer)
"""
sub_agent_1 = LlmAgent(
name="sub_agent_1",
model=testing_utils.MockModel.create(
responses=[
"response from sub_agent_1",
"second response from sub_agent_1",
]
),
)
root_agent = LlmAgent(
name="root_agent",
model=testing_utils.MockModel.create(
responses=[transfer_call_part("sub_agent_1")]
),
sub_agents=[sub_agent_1],
)
runner = testing_utils.InMemoryRunner(
app=App(
name="test_app",
root_agent=root_agent,
resumability_config=ResumabilityConfig(is_resumable=True),
)
)
# Invocation 1: root transfers to sub_agent_1.
inv1_events = await runner.run_async("test query")
inv1_behavioral = _behavioral_events(inv1_events)
assert inv1_behavioral == [
("root_agent", transfer_call_part("sub_agent_1")),
("root_agent", TRANSFER_RESPONSE_PART),
("root_agent", END_OF_AGENT),
("sub_agent_1", "response from sub_agent_1"),
("sub_agent_1", END_OF_AGENT),
]
# Invocation 2: sub_agent_1 is now active, should respond directly.
inv2_events = await runner.run_async("follow up query")
inv2_behavioral = _behavioral_events(inv2_events)
assert inv2_behavioral == [
("sub_agent_1", "second response from sub_agent_1"),
("sub_agent_1", END_OF_AGENT),
]
@pytest.mark.asyncio
async def test_resume_from_model_response():
"""Tests that the root agent resumes when there has been no transfer."""
root_agent = LlmAgent(
name="root_agent",
model=testing_utils.MockModel.create(
responses=[
"first response from root",
"second response from root",
]
),
)
runner = testing_utils.InMemoryRunner(
app=App(
name="test_app",
root_agent=root_agent,
resumability_config=ResumabilityConfig(is_resumable=True),
)
)
# Invocation 1: root responds normally.
inv1_events = await runner.run_async("test query")
inv1_behavioral = _behavioral_events(inv1_events)
assert inv1_behavioral == [
("root_agent", "first response from root"),
("root_agent", END_OF_AGENT),
]
# Invocation 2: root should respond again (no transfer happened).
inv2_events = await runner.run_async("follow up")
inv2_behavioral = _behavioral_events(inv2_events)
assert inv2_behavioral == [
("root_agent", "second response from root"),
("root_agent", END_OF_AGENT),
]
@pytest.mark.asyncio
async def test_resume_from_tool_call():
"""Tests that the agent resumes from a tool call.
invocation1: root_agent calls some_tool, gets response, then responds
invocation2: root_agent responds again (tool was non-long-running)
"""
root_agent = LlmAgent(
name="root_agent",
model=testing_utils.MockModel.create(
responses=[
Part.from_function_call(name="some_tool", args={}),
"response after tool call",
"second response",
]
),
tools=[some_tool],
)
runner = testing_utils.InMemoryRunner(
app=App(
name="test_app",
root_agent=root_agent,
resumability_config=ResumabilityConfig(is_resumable=True),
)
)
# Invocation 1: root calls tool, gets response, responds.
inv1_events = await runner.run_async("test query")
inv1_behavioral = _behavioral_events(inv1_events)
assert inv1_behavioral == [
("root_agent", Part.from_function_call(name="some_tool", args={})),
(
"root_agent",
Part.from_function_response(
name="some_tool", response={"result": "ok"}
),
),
("root_agent", "response after tool call"),
("root_agent", END_OF_AGENT),
]
# Invocation 2: root resumes normally.
inv2_events = await runner.run_async("follow up")
inv2_behavioral = _behavioral_events(inv2_events)
assert inv2_behavioral == [
("root_agent", "second response"),
("root_agent", END_OF_AGENT),
]
@pytest.mark.asyncio
async def test_resume_subagent_after_transfer_and_tool_call():
"""Tests resuming a sub-agent that called a tool after being transferred to.
invocation1: root_agent transfers to sub_agent_1, sub_agent_1 calls tool
and responds
invocation2: sub_agent_1 is still active, responds directly
"""
def sub_agent_tool():
return {"result": "ok"}
sub_agent_1 = LlmAgent(
name="sub_agent_1",
model=testing_utils.MockModel.create(
responses=[
Part.from_function_call(name="sub_agent_tool", args={}),
"response from sub_agent_1 after tool",
"second response from sub_agent_1",
]
),
tools=[sub_agent_tool],
)
root_agent = LlmAgent(
name="root_agent",
model=testing_utils.MockModel.create(
responses=[transfer_call_part("sub_agent_1")]
),
sub_agents=[sub_agent_1],
)
runner = testing_utils.InMemoryRunner(
app=App(
name="test_app",
root_agent=root_agent,
resumability_config=ResumabilityConfig(is_resumable=True),
)
)
# Invocation 1: root transfers, sub_agent calls tool and responds.
inv1_events = await runner.run_async("test query")
inv1_behavioral = _behavioral_events(inv1_events)
assert inv1_behavioral == [
("root_agent", transfer_call_part("sub_agent_1")),
("root_agent", TRANSFER_RESPONSE_PART),
("root_agent", END_OF_AGENT),
(
"sub_agent_1",
Part.from_function_call(name="sub_agent_tool", args={}),
),
(
"sub_agent_1",
Part.from_function_response(
name="sub_agent_tool", response={"result": "ok"}
),
),
("sub_agent_1", "response from sub_agent_1 after tool"),
("sub_agent_1", END_OF_AGENT),
]
# Invocation 2: sub_agent_1 is still active, responds directly.
inv2_events = await runner.run_async("follow up")
inv2_behavioral = _behavioral_events(inv2_events)
assert inv2_behavioral == [
("sub_agent_1", "second response from sub_agent_1"),
("sub_agent_1", END_OF_AGENT),
]
+139
View File
@@ -0,0 +1,139 @@
# 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.
import sys
from unittest.mock import ANY
from unittest.mock import patch
import warnings
from google.adk.agents.run_config import RunConfig
from google.genai import types
import pytest
def test_validate_max_llm_calls_valid():
value = RunConfig.validate_max_llm_calls(100)
assert value == 100
def test_validate_max_llm_calls_negative():
with patch("google.adk.agents.run_config.logger.warning") as mock_warning:
value = RunConfig.validate_max_llm_calls(-1)
mock_warning.assert_called_once_with(ANY)
assert value == -1
def test_validate_max_llm_calls_warns_on_zero():
with patch("google.adk.agents.run_config.logger.warning") as mock_warning:
value = RunConfig.validate_max_llm_calls(0)
mock_warning.assert_called_once_with(ANY)
assert value == 0
def test_validate_max_llm_calls_too_large():
with pytest.raises(
ValueError, match=f"max_llm_calls should be less than {sys.maxsize}."
):
RunConfig.validate_max_llm_calls(sys.maxsize)
def test_audio_transcription_configs_are_not_shared_between_instances():
config1 = RunConfig()
config2 = RunConfig()
# Validate output_audio_transcription
assert config1.output_audio_transcription is not None
assert config2.output_audio_transcription is not None
assert (
config1.output_audio_transcription
is not config2.output_audio_transcription
)
# Validate input_audio_transcription
assert config1.input_audio_transcription is not None
assert config2.input_audio_transcription is not None
assert (
config1.input_audio_transcription is not config2.input_audio_transcription
)
def test_response_modalities_accepts_enum():
config = RunConfig(response_modalities=[types.Modality.AUDIO])
assert config.response_modalities == [types.Modality.AUDIO]
assert isinstance(config.response_modalities[0], types.Modality)
def test_response_modalities_coerces_string_to_enum():
config = RunConfig(response_modalities=["AUDIO"])
assert config.response_modalities == [types.Modality.AUDIO]
assert isinstance(config.response_modalities[0], types.Modality)
def test_response_modalities_coerces_lowercase_string_to_enum():
config = RunConfig(response_modalities=["audio"])
assert config.response_modalities == [types.Modality.AUDIO]
assert isinstance(config.response_modalities[0], types.Modality)
def test_response_modalities_serialization_no_warning():
config = RunConfig(response_modalities=[types.Modality.AUDIO])
live_config = types.LiveConnectConfig()
live_config.response_modalities = config.response_modalities
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
live_config.model_dump()
pydantic_warnings = [
x for x in w if "PydanticSerializationUnexpectedValue" in str(x.message)
]
assert len(pydantic_warnings) == 0
def test_avatar_config_initialization():
custom_avatar = types.CustomizedAvatar(
image_mime_type="image/jpeg", image_data=b"image_bytes"
)
avatar_config = types.AvatarConfig(
audio_bitrate_bps=128000,
video_bitrate_bps=1000000,
customized_avatar=custom_avatar,
)
run_config = RunConfig(avatar_config=avatar_config)
assert run_config.avatar_config == avatar_config
assert run_config.avatar_config.customized_avatar == custom_avatar
assert (
run_config.avatar_config.customized_avatar.image_mime_type == "image/jpeg"
)
assert run_config.avatar_config.customized_avatar.image_data == b"image_bytes"
def test_avatar_config_with_name():
avatar_config = types.AvatarConfig(
audio_bitrate_bps=128000,
video_bitrate_bps=1000000,
avatar_name="test_avatar",
)
run_config = RunConfig(avatar_config=avatar_config)
assert run_config.avatar_config == avatar_config
assert run_config.avatar_config.avatar_name == "test_avatar"
assert run_config.avatar_config.customized_avatar is None
def test_model_input_context_accepts_transient_contents():
context_content = types.UserContent("Relevant context for this turn")
run_config = RunConfig(model_input_context=[context_content])
assert run_config.model_input_context == [context_content]
@@ -0,0 +1,208 @@
# 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.
"""Testings for the SequentialAgent."""
from typing import AsyncGenerator
from google.adk.agents.base_agent import BaseAgent
from google.adk.agents.invocation_context import InvocationContext
from google.adk.agents.sequential_agent import SequentialAgent
from google.adk.agents.sequential_agent import SequentialAgentState
from google.adk.apps import ResumabilityConfig
from google.adk.events.event import Event
from google.adk.sessions.in_memory_session_service import InMemorySessionService
from google.genai import types
import pytest
from typing_extensions import override
class _TestingAgent(BaseAgent):
@override
async def _run_async_impl(
self, ctx: InvocationContext
) -> AsyncGenerator[Event, None]:
yield Event(
author=self.name,
invocation_id=ctx.invocation_id,
content=types.Content(
parts=[types.Part(text=f'Hello, async {self.name}!')]
),
)
@override
async def _run_live_impl(
self, ctx: InvocationContext
) -> AsyncGenerator[Event, None]:
yield Event(
author=self.name,
invocation_id=ctx.invocation_id,
content=types.Content(
parts=[types.Part(text=f'Hello, live {self.name}!')]
),
)
async def _create_parent_invocation_context(
test_name: str, agent: BaseAgent, resumable: bool = False
) -> InvocationContext:
session_service = InMemorySessionService()
session = await session_service.create_session(
app_name='test_app', user_id='test_user'
)
return InvocationContext(
invocation_id=f'{test_name}_invocation_id',
agent=agent,
session=session,
session_service=session_service,
resumability_config=ResumabilityConfig(is_resumable=resumable),
)
@pytest.mark.asyncio
async def test_run_async(request: pytest.FixtureRequest):
agent_1 = _TestingAgent(name=f'{request.function.__name__}_test_agent_1')
agent_2 = _TestingAgent(name=f'{request.function.__name__}_test_agent_2')
sequential_agent = SequentialAgent(
name=f'{request.function.__name__}_test_agent',
sub_agents=[
agent_1,
agent_2,
],
)
parent_ctx = await _create_parent_invocation_context(
request.function.__name__, sequential_agent
)
events = [e async for e in sequential_agent.run_async(parent_ctx)]
assert len(events) == 2
assert events[0].author == agent_1.name
assert events[1].author == agent_2.name
assert events[0].content.parts[0].text == f'Hello, async {agent_1.name}!'
assert events[1].content.parts[0].text == f'Hello, async {agent_2.name}!'
@pytest.mark.asyncio
async def test_run_async_skip_if_no_sub_agent(request: pytest.FixtureRequest):
sequential_agent = SequentialAgent(
name=f'{request.function.__name__}_test_agent',
sub_agents=[],
)
parent_ctx = await _create_parent_invocation_context(
request.function.__name__, sequential_agent
)
events = [e async for e in sequential_agent.run_async(parent_ctx)]
assert not events
@pytest.mark.asyncio
async def test_run_async_with_resumability(request: pytest.FixtureRequest):
agent_1 = _TestingAgent(name=f'{request.function.__name__}_test_agent_1')
agent_2 = _TestingAgent(name=f'{request.function.__name__}_test_agent_2')
sequential_agent = SequentialAgent(
name=f'{request.function.__name__}_test_agent',
sub_agents=[
agent_1,
agent_2,
],
)
parent_ctx = await _create_parent_invocation_context(
request.function.__name__, sequential_agent, resumable=True
)
events = [e async for e in sequential_agent.run_async(parent_ctx)]
# 5 events:
# 1. SequentialAgent checkpoint event for agent 1
# 2. Agent 1 event
# 3. SequentialAgent checkpoint event for agent 2
# 4. Agent 2 event
# 5. SequentialAgent final checkpoint event
assert len(events) == 5
assert events[0].author == sequential_agent.name
assert not events[0].actions.end_of_agent
assert events[0].actions.agent_state['current_sub_agent'] == agent_1.name
assert events[1].author == agent_1.name
assert events[1].content.parts[0].text == f'Hello, async {agent_1.name}!'
assert events[2].author == sequential_agent.name
assert not events[2].actions.end_of_agent
assert events[2].actions.agent_state['current_sub_agent'] == agent_2.name
assert events[3].author == agent_2.name
assert events[3].content.parts[0].text == f'Hello, async {agent_2.name}!'
assert events[4].author == sequential_agent.name
assert events[4].actions.end_of_agent
@pytest.mark.asyncio
async def test_resume_async(request: pytest.FixtureRequest):
agent_1 = _TestingAgent(name=f'{request.function.__name__}_test_agent_1')
agent_2 = _TestingAgent(name=f'{request.function.__name__}_test_agent_2')
sequential_agent = SequentialAgent(
name=f'{request.function.__name__}_test_agent',
sub_agents=[
agent_1,
agent_2,
],
)
parent_ctx = await _create_parent_invocation_context(
request.function.__name__, sequential_agent, resumable=True
)
parent_ctx.agent_states[sequential_agent.name] = SequentialAgentState(
current_sub_agent=agent_2.name
).model_dump(mode='json')
events = [e async for e in sequential_agent.run_async(parent_ctx)]
# 2 events:
# 1. Agent 2 event
# 2. SequentialAgent final checkpoint event
assert len(events) == 2
assert events[0].author == agent_2.name
assert events[0].content.parts[0].text == f'Hello, async {agent_2.name}!'
assert events[1].author == sequential_agent.name
assert events[1].actions.end_of_agent
@pytest.mark.asyncio
async def test_run_live(request: pytest.FixtureRequest):
agent_1 = _TestingAgent(name=f'{request.function.__name__}_test_agent_1')
agent_2 = _TestingAgent(name=f'{request.function.__name__}_test_agent_2')
sequential_agent = SequentialAgent(
name=f'{request.function.__name__}_test_agent',
sub_agents=[
agent_1,
agent_2,
],
)
parent_ctx = await _create_parent_invocation_context(
request.function.__name__, sequential_agent
)
events = [e async for e in sequential_agent.run_live(parent_ctx)]
assert len(events) == 2
assert events[0].author == agent_1.name
assert events[1].author == agent_2.name
assert events[0].content.parts[0].text == f'Hello, live {agent_1.name}!'
assert events[1].content.parts[0].text == f'Hello, live {agent_2.name}!'
def test_deprecation_mentions_sub_agent_limitation():
with pytest.warns(DeprecationWarning, match='sub-agent'):
SequentialAgent(name='deprecated_sequential', sub_agents=[])