ec2b666284
Continuous Integration / Pre-commit Linter (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.10) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.11) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.12) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.12) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Has been cancelled
Copybara PR Handler / close-imported-pr (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Has been cancelled
364 lines
12 KiB
Python
364 lines
12 KiB
Python
# Copyright 2026 Google LLC
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""Tests for 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']
|