Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 34 additions & 3 deletions openhands-sdk/openhands/sdk/llm/mixins/fn_call_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
101 changes: 101 additions & 0 deletions tests/sdk/llm/test_llm_fncall_converter.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Test for FunctionCallingConverter."""

import json
from textwrap import dedent
from typing import cast

import pytest
Expand Down Expand Up @@ -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"
"<function=get_resource>\n"
f"<parameter=resourceId>{resourceId}</parameter>\n"
f"<parameter=temperature>{temperature}</parameter>\n"
f"<parameter=images>{images}</parameter>\n"
"</function>"
),
"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
)
Loading