mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: openhands <openhands@all-hands.dev> Co-authored-by: Graham Neubig <neubig@gmail.com> Co-authored-by: llamantino <213239228+llamantino@users.noreply.github.com> Co-authored-by: mamoodi <mamoodiha@gmail.com> Co-authored-by: Tim O'Farrell <tofarr@gmail.com> Co-authored-by: Hiep Le <69354317+hieptl@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Ryan H. Tran <descience.thh10@gmail.com> Co-authored-by: Neeraj Panwar <49247372+npneeraj@users.noreply.github.com> Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com> Co-authored-by: Insop <1240382+insop@users.noreply.github.com> Co-authored-by: test <test@test.com> Co-authored-by: Engel Nyst <enyst@users.noreply.github.com> Co-authored-by: Zhonghao Jiang <zhonghao.J@outlook.com> Co-authored-by: Ray Myers <ray.myers@gmail.com>
275 lines
9.3 KiB
Python
275 lines
9.3 KiB
Python
"""Test function calling module."""
|
|
|
|
import json
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from litellm import ModelResponse
|
|
|
|
from openhands.agenthub.codeact_agent.function_calling import response_to_actions
|
|
from openhands.core.exceptions import FunctionCallValidationError
|
|
from openhands.events.action import (
|
|
BrowseInteractiveAction,
|
|
CmdRunAction,
|
|
FileEditAction,
|
|
FileReadAction,
|
|
IPythonRunCellAction,
|
|
)
|
|
from openhands.events.event import FileEditSource, FileReadSource
|
|
|
|
|
|
def create_mock_response(function_name: str, arguments: dict) -> ModelResponse:
|
|
"""Helper function to create a mock response with a tool call."""
|
|
return ModelResponse(
|
|
id='mock-id',
|
|
choices=[
|
|
{
|
|
'message': {
|
|
'tool_calls': [
|
|
{
|
|
'function': {
|
|
'name': function_name,
|
|
'arguments': json.dumps(arguments),
|
|
},
|
|
'id': 'mock-tool-call-id',
|
|
'type': 'function',
|
|
}
|
|
],
|
|
'content': None,
|
|
'role': 'assistant',
|
|
},
|
|
'index': 0,
|
|
'finish_reason': 'tool_calls',
|
|
}
|
|
],
|
|
)
|
|
|
|
|
|
def test_execute_bash_valid():
|
|
"""Test execute_bash with valid arguments."""
|
|
response = create_mock_response(
|
|
'execute_bash', {'command': 'ls', 'is_input': 'false', 'security_risk': 'LOW'}
|
|
)
|
|
actions = response_to_actions(response)
|
|
assert len(actions) == 1
|
|
assert isinstance(actions[0], CmdRunAction)
|
|
assert actions[0].command == 'ls'
|
|
assert actions[0].is_input is False
|
|
|
|
# Test with timeout parameter
|
|
with patch.object(CmdRunAction, 'set_hard_timeout') as mock_set_hard_timeout:
|
|
response_with_timeout = create_mock_response(
|
|
'execute_bash',
|
|
{
|
|
'command': 'ls',
|
|
'is_input': 'false',
|
|
'timeout': 30,
|
|
'security_risk': 'LOW',
|
|
},
|
|
)
|
|
actions_with_timeout = response_to_actions(response_with_timeout)
|
|
|
|
# Verify set_hard_timeout was called with the correct value
|
|
mock_set_hard_timeout.assert_called_once_with(30.0)
|
|
|
|
assert len(actions_with_timeout) == 1
|
|
assert isinstance(actions_with_timeout[0], CmdRunAction)
|
|
assert actions_with_timeout[0].command == 'ls'
|
|
assert actions_with_timeout[0].is_input is False
|
|
|
|
|
|
def test_execute_bash_missing_command():
|
|
"""Test execute_bash with missing command argument."""
|
|
response = create_mock_response(
|
|
'execute_bash', {'is_input': 'false', 'security_risk': 'LOW'}
|
|
)
|
|
with pytest.raises(FunctionCallValidationError) as exc_info:
|
|
response_to_actions(response)
|
|
assert 'Missing required argument "command"' in str(exc_info.value)
|
|
|
|
|
|
def test_execute_ipython_cell_valid():
|
|
"""Test execute_ipython_cell with valid arguments."""
|
|
response = create_mock_response(
|
|
'execute_ipython_cell', {'code': "print('hello')", 'security_risk': 'LOW'}
|
|
)
|
|
actions = response_to_actions(response)
|
|
assert len(actions) == 1
|
|
assert isinstance(actions[0], IPythonRunCellAction)
|
|
assert actions[0].code == "print('hello')"
|
|
|
|
|
|
def test_execute_ipython_cell_missing_code():
|
|
"""Test execute_ipython_cell with missing code argument."""
|
|
response = create_mock_response('execute_ipython_cell', {'security_risk': 'LOW'})
|
|
with pytest.raises(FunctionCallValidationError) as exc_info:
|
|
response_to_actions(response)
|
|
assert 'Missing required argument "code"' in str(exc_info.value)
|
|
|
|
|
|
def test_edit_file_valid():
|
|
"""Test edit_file with valid arguments."""
|
|
response = create_mock_response(
|
|
'edit_file',
|
|
{
|
|
'path': '/path/to/file',
|
|
'content': 'file content',
|
|
'start': 1,
|
|
'end': 10,
|
|
'security_risk': 'LOW',
|
|
},
|
|
)
|
|
actions = response_to_actions(response)
|
|
assert len(actions) == 1
|
|
assert isinstance(actions[0], FileEditAction)
|
|
assert actions[0].path == '/path/to/file'
|
|
assert actions[0].content == 'file content'
|
|
assert actions[0].start == 1
|
|
assert actions[0].end == 10
|
|
|
|
|
|
def test_edit_file_missing_required():
|
|
"""Test edit_file with missing required arguments."""
|
|
# Missing path
|
|
response = create_mock_response(
|
|
'edit_file', {'content': 'content', 'security_risk': 'LOW'}
|
|
)
|
|
with pytest.raises(FunctionCallValidationError) as exc_info:
|
|
response_to_actions(response)
|
|
assert 'Missing required argument "path"' in str(exc_info.value)
|
|
|
|
# Missing content
|
|
response = create_mock_response(
|
|
'edit_file', {'path': '/path/to/file', 'security_risk': 'LOW'}
|
|
)
|
|
with pytest.raises(FunctionCallValidationError) as exc_info:
|
|
response_to_actions(response)
|
|
assert 'Missing required argument "content"' in str(exc_info.value)
|
|
|
|
|
|
def test_str_replace_editor_valid():
|
|
"""Test str_replace_editor with valid arguments."""
|
|
# Test view command
|
|
response = create_mock_response(
|
|
'str_replace_editor',
|
|
{'command': 'view', 'path': '/path/to/file', 'security_risk': 'LOW'},
|
|
)
|
|
actions = response_to_actions(response)
|
|
assert len(actions) == 1
|
|
assert isinstance(actions[0], FileReadAction)
|
|
assert actions[0].path == '/path/to/file'
|
|
assert actions[0].impl_source == FileReadSource.OH_ACI
|
|
|
|
# Test other commands
|
|
response = create_mock_response(
|
|
'str_replace_editor',
|
|
{
|
|
'command': 'str_replace',
|
|
'path': '/path/to/file',
|
|
'old_str': 'old',
|
|
'new_str': 'new',
|
|
'security_risk': 'LOW',
|
|
},
|
|
)
|
|
actions = response_to_actions(response)
|
|
assert len(actions) == 1
|
|
assert isinstance(actions[0], FileEditAction)
|
|
assert actions[0].path == '/path/to/file'
|
|
assert actions[0].impl_source == FileEditSource.OH_ACI
|
|
|
|
|
|
def test_str_replace_editor_missing_required():
|
|
"""Test str_replace_editor with missing required arguments."""
|
|
# Missing command
|
|
response = create_mock_response(
|
|
'str_replace_editor', {'path': '/path/to/file', 'security_risk': 'LOW'}
|
|
)
|
|
with pytest.raises(FunctionCallValidationError) as exc_info:
|
|
response_to_actions(response)
|
|
assert 'Missing required argument "command"' in str(exc_info.value)
|
|
|
|
# Missing path
|
|
response = create_mock_response(
|
|
'str_replace_editor', {'command': 'view', 'security_risk': 'LOW'}
|
|
)
|
|
with pytest.raises(FunctionCallValidationError) as exc_info:
|
|
response_to_actions(response)
|
|
assert 'Missing required argument "path"' in str(exc_info.value)
|
|
|
|
|
|
def test_browser_valid():
|
|
"""Test browser with valid arguments."""
|
|
response = create_mock_response(
|
|
'browser', {'code': "click('button-1')", 'security_risk': 'LOW'}
|
|
)
|
|
actions = response_to_actions(response)
|
|
assert len(actions) == 1
|
|
assert isinstance(actions[0], BrowseInteractiveAction)
|
|
assert actions[0].browser_actions == "click('button-1')"
|
|
assert actions[0].return_axtree is False # Default value should be False
|
|
|
|
|
|
def test_browser_missing_code():
|
|
"""Test browser with missing code argument."""
|
|
response = create_mock_response('browser', {'security_risk': 'LOW'})
|
|
with pytest.raises(FunctionCallValidationError) as exc_info:
|
|
response_to_actions(response)
|
|
assert 'Missing required argument "code"' in str(exc_info.value)
|
|
|
|
|
|
def test_invalid_json_arguments():
|
|
"""Test handling of invalid JSON in arguments."""
|
|
response = ModelResponse(
|
|
id='mock-id',
|
|
choices=[
|
|
{
|
|
'message': {
|
|
'tool_calls': [
|
|
{
|
|
'function': {
|
|
'name': 'execute_bash',
|
|
'arguments': 'invalid json',
|
|
},
|
|
'id': 'mock-tool-call-id',
|
|
'type': 'function',
|
|
}
|
|
],
|
|
'content': None,
|
|
'role': 'assistant',
|
|
},
|
|
'index': 0,
|
|
'finish_reason': 'tool_calls',
|
|
}
|
|
],
|
|
)
|
|
with pytest.raises(FunctionCallValidationError) as exc_info:
|
|
response_to_actions(response)
|
|
assert 'Failed to parse tool call arguments' in str(exc_info.value)
|
|
|
|
|
|
def test_unexpected_argument_handling():
|
|
"""Test that unexpected arguments in function calls are properly handled.
|
|
|
|
This test reproduces issue #8369 Example 4 where an unexpected argument
|
|
(old_str_prefix) causes a TypeError.
|
|
"""
|
|
response = create_mock_response(
|
|
'str_replace_editor',
|
|
{
|
|
'command': 'str_replace',
|
|
'path': '/test/file.py',
|
|
'old_str': 'def test():\n pass',
|
|
'new_str': 'def test():\n return True',
|
|
'old_str_prefix': 'some prefix', # Unexpected argument
|
|
'security_risk': 'LOW',
|
|
},
|
|
)
|
|
|
|
# Test that the function raises a FunctionCallValidationError
|
|
with pytest.raises(FunctionCallValidationError) as exc_info:
|
|
response_to_actions(response)
|
|
|
|
# Verify the error message mentions the unexpected argument
|
|
assert 'old_str_prefix' in str(exc_info.value)
|
|
assert 'Unexpected argument' in str(exc_info.value)
|