diff --git a/openhands-sdk/openhands/sdk/llm/mixins/fn_call_converter.py b/openhands-sdk/openhands/sdk/llm/mixins/fn_call_converter.py index 87ac91b52..28b2ed96b 100644 --- a/openhands-sdk/openhands/sdk/llm/mixins/fn_call_converter.py +++ b/openhands-sdk/openhands/sdk/llm/mixins/fn_call_converter.py @@ -478,6 +478,8 @@ def convert_tool_call_to_string(tool_call: dict) -> str: ret += "\n" if isinstance(param_value, list) or isinstance(param_value, dict): ret += json.dumps(param_value) + elif isinstance(param_value, bool): + ret += str(param_value).lower() else: ret += f"{param_value}" if is_multiline: @@ -760,16 +762,45 @@ def _extract_and_validate_params( ) # Validate and convert parameter type - # supported: string, integer, array + # supported: string, integer, float, number, boolean, array if param_name in param_name_to_type: - if param_name_to_type[param_name] == "integer": + param_type = param_name_to_type[param_name] + if param_type == "integer": try: param_value = int(param_value) except ValueError: raise FunctionCallValidationError( f"Parameter '{param_name}' is expected to be an integer." ) - elif param_name_to_type[param_name] == "array": + elif param_type == "float": + try: + param_value = float(param_value) + except ValueError: + raise FunctionCallValidationError( + f"Parameter '{param_name}' is expected to be a float." + ) + elif param_type == "number": + try: + float_value = float(param_value) + if float_value.is_integer(): + param_value = int(float_value) + else: + param_value = float_value + except ValueError: + raise FunctionCallValidationError( + f"Parameter '{param_name}' is expected to be a number." + ) + elif param_type == "boolean": + match str(param_value).strip().lower(): + case "true" | "1" | "y" | "yes" | "accept" | "ok" | "okay": + param_value = True + case "false" | "0" | "n" | "no" | "deny": + param_value = False + case _: + raise FunctionCallValidationError( + f"Parameter '{param_name}' is expected to be a boolean." + ) + elif param_type == "array": try: param_value = json.loads(param_value) except json.JSONDecodeError: diff --git a/tests/sdk/llm/test_llm_fncall_converter.py b/tests/sdk/llm/test_llm_fncall_converter.py index ef2018862..b19d26b57 100644 --- a/tests/sdk/llm/test_llm_fncall_converter.py +++ b/tests/sdk/llm/test_llm_fncall_converter.py @@ -1,6 +1,7 @@ """Test for FunctionCallingConverter.""" import json +from textwrap import dedent from typing import cast import pytest @@ -769,3 +770,103 @@ def test_convert_fncall_messages_with_image_url(): image_content["image_url"]["url"] == "data:image/gif;base64,R0lGODlhAQABAAAAACw=" ) + + +# Additional tools to verify number and boolean coercion behavior +COERCE_TOOLS: list[ChatCompletionToolParam] = [ + { + "type": "function", + "function": { + "name": "get_resource", + "description": "Get a resource from the store", + "parameters": { + "type": "object", + "properties": { + "resourceId": {"type": "integer"}, + "temperature": {"type": "number"}, + "images": {"type": "boolean"}, + }, + "required": ["resourceId"], + }, + }, + } +] + + +def numeric_coercion_msg( + resourceId: str = "1", temperature: str = "0.7", images: str = "false" +) -> list[dict]: + return [ + { + "content": [ + { + "text": dedent( + "Let's look at the resource mentioned in the task:\n\n" + "\n" + f"{resourceId}\n" + f"{temperature}\n" + f"{images}\n" + "" + ), + "type": "text", + } + ], + "role": "assistant", + } + ] + + +def test_numeric_type_conversions(): + fncall_messages = [ + { + "content": [ + { + "type": "text", + "text": "Let's look at the resource mentioned in the task:", + } + ], + "role": "assistant", + "tool_calls": [ + { + "index": 1, + "function": { + "arguments": ( + '{"resourceId": 1, "temperature": 0.7, "images": false}' + ), + "name": "get_resource", + }, + "id": "toolu_01", + "type": "function", + } + ], + } + ] + + non_fncall_messages = numeric_coercion_msg() + + converted_non_fncall = convert_fncall_messages_to_non_fncall_messages( + fncall_messages, COERCE_TOOLS + ) + assert converted_non_fncall == non_fncall_messages + + converted_fncall = convert_non_fncall_messages_to_fncall_messages( + non_fncall_messages, COERCE_TOOLS + ) + assert converted_fncall == fncall_messages + + +def test_invalid_numeric_types_error(): + with pytest.raises(FunctionCallValidationError): + convert_non_fncall_messages_to_fncall_messages( + numeric_coercion_msg(resourceId="one"), COERCE_TOOLS + ) + + with pytest.raises(FunctionCallValidationError): + convert_non_fncall_messages_to_fncall_messages( + numeric_coercion_msg(temperature="False"), COERCE_TOOLS + ) + + with pytest.raises(FunctionCallValidationError): + convert_non_fncall_messages_to_fncall_messages( + numeric_coercion_msg(images="nah"), COERCE_TOOLS + )