Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
119 changes: 115 additions & 4 deletions openhands-sdk/openhands/sdk/llm/mixins/fn_call_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,116 @@ def convert_tool_call_to_string(tool_call: dict) -> str:
return ret


def _summarize_schema_type(schema: object | None) -> str:
"""
Capture array, union, enum, and nested type info.
"""
if not isinstance(schema, dict):
return "unknown" if schema is None else str(schema)

union_keys = ("anyOf", "oneOf", "allOf")
for key in union_keys:
if key in schema:
return " or ".join(_summarize_schema_type(option) for option in schema[key])

schema_type = schema.get("type")
if isinstance(schema_type, list):
return " or ".join(str(t) for t in schema_type)
if schema_type == "array":
items = schema.get("items")
if isinstance(items, list):
item_types = ", ".join(_summarize_schema_type(item) for item in items)
return f"array[{item_types}]"
if isinstance(items, dict):
return f"array[{_summarize_schema_type(items)}]"
return "array"
if schema_type:
return str(schema_type)
if "enum" in schema:
return "enum"
return "unknown"


def _format_schema_detail(schema: object | None, indent: int = 4) -> list[str]:
"""
Recursively describe arrays, objects, unions, and additional properties.
"""
if not isinstance(schema, dict):
return []

indent_str = " " * indent
lines: list[str] = []

# Handle union types
union_keys = ("anyOf", "oneOf", "allOf")
for key in union_keys:
if key in schema:
lines.append(f"{indent_str}{key} options:")
for option in schema[key]:
option_type = _summarize_schema_type(option)
option_line = f"{' ' * (indent + 2)}- {option_type}"
option_desc = (
option.get("description") if isinstance(option, dict) else None
)
if option_desc:
option_line += f": {option_desc}"
lines.append(option_line)
lines.extend(_format_schema_detail(option, indent + 4))
return lines

schema_type = schema.get("type")
if isinstance(schema_type, list):
lines.append(
f"{indent_str}Allowed types: {', '.join(str(t) for t in schema_type)}"
)
return lines

# Handle array type
if schema_type == "array":
items = schema.get("items")
lines.append(f"{indent_str}Array items:")
if isinstance(items, list):
for index, item_schema in enumerate(items):
item_type = _summarize_schema_type(item_schema)
lines.append(f"{' ' * (indent + 2)}- index {index}: {item_type}")
lines.extend(_format_schema_detail(item_schema, indent + 4))
elif isinstance(items, dict):
lines.append(f"{' ' * (indent + 2)}Type: {_summarize_schema_type(items)}")
lines.extend(_format_schema_detail(items, indent + 4))
else:
lines.append(f"{' ' * (indent + 2)}Type: unknown")
return lines

if schema_type == "object":
properties = schema.get("properties", {})
required = set(schema.get("required", []))
if isinstance(properties, dict) and properties:
lines.append(f"{indent_str}Object properties:")
for name, prop in properties.items():
prop_type = _summarize_schema_type(prop)
required_flag = "required" if name in required else "optional"
prop_desc = prop.get("description", "No description provided")
lines.append(
f"{' ' * (indent + 2)}- {name} ({prop_type}, {required_flag}):"
f" {prop_desc}"
)
lines.extend(_format_schema_detail(prop, indent + 4))
additional_props = schema.get("additionalProperties")
if isinstance(additional_props, dict):
lines.append(
f"{indent_str}Additional properties allowed: "
f"{_summarize_schema_type(additional_props)}"
)
lines.extend(_format_schema_detail(additional_props, indent + 2))
elif additional_props is True:
lines.append(f"{indent_str}Additional properties allowed.")
elif additional_props is False:
lines.append(f"{indent_str}Additional properties not allowed.")
return lines

return lines


def convert_tools_to_description(tools: list[ChatCompletionToolParam]) -> str:
ret = ""
for i, tool in enumerate(tools):
Expand All @@ -469,22 +579,23 @@ def convert_tools_to_description(tools: list[ChatCompletionToolParam]) -> str:
required_params = set(fn["parameters"].get("required", []))

for j, (param_name, param_info) in enumerate(properties.items()):
# Indicate required/optional in parentheses with type
is_required = param_name in required_params
param_status = "required" if is_required else "optional"
param_type = param_info.get("type", "string")
param_type = _summarize_schema_type(param_info)

# Get parameter description
desc = param_info.get("description", "No description provided")

# Handle enum values if present
if "enum" in param_info:
enum_values = ", ".join(f"`{v}`" for v in param_info["enum"])
desc += f"\nAllowed values: [{enum_values}]"

ret += (
f" ({j + 1}) {param_name} ({param_type}, {param_status}): {desc}\n"
)

detail_lines = _format_schema_detail(param_info, indent=6)
if detail_lines:
ret += "\n".join(detail_lines) + "\n"
else:
ret += "No parameters are required for this function.\n"

Expand Down
151 changes: 151 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
import textwrap

import pytest
from litellm import ChatCompletionToolParam
Expand All @@ -14,6 +15,7 @@
convert_fncall_messages_to_non_fncall_messages,
convert_non_fncall_messages_to_fncall_messages,
convert_tool_call_to_string,
convert_tools_to_description,
)


Expand Down Expand Up @@ -689,3 +691,152 @@ def test_convert_fncall_messages_with_image_url():
image_content["image_url"]["url"]
== "data:image/gif;base64,R0lGODlhAQABAAAAACw="
)


def test_convert_tools_to_description_nested_array():
tools: list[ChatCompletionToolParam] = [
{
"type": "function",
"function": {
"name": "nested_array",
"description": "Handle nested arrays",
"parameters": {
"type": "object",
"properties": {
"items": {
"type": "array",
"description": "List of entries",
"items": {
"type": "object",
"properties": {
"value": {
"type": "integer",
"description": "The numeric value",
}
},
"required": ["value"],
},
}
},
"required": ["items"],
},
},
}
]

result = convert_tools_to_description(tools)

expected = textwrap.dedent(
"""\
---- BEGIN FUNCTION #1: nested_array ----
Description: Handle nested arrays
Parameters:
(1) items (array[object], required): List of entries
Array items:
Type: object
Object properties:
- value (integer, required): The numeric value
---- END FUNCTION #1 ----
"""
)

assert result.strip() == expected.strip()


def test_convert_tools_to_description_union_options():
tools: list[ChatCompletionToolParam] = [
{
"type": "function",
"function": {
"name": "union_tool",
"description": "Test union parameter",
"parameters": {
"type": "object",
"properties": {
"filters": {
"description": "Supported filters",
"anyOf": [
{"type": "string", "description": "match by name"},
{"type": "integer", "description": "match by id"},
],
}
},
},
},
}
]

result = convert_tools_to_description(tools)

expected = textwrap.dedent(
"""\
---- BEGIN FUNCTION #1: union_tool ----
Description: Test union parameter
Parameters:
(1) filters (string or integer, optional): Supported filters
anyOf options:
- string: match by name
- integer: match by id
---- END FUNCTION #1 ----
"""
)

assert result.strip() == expected.strip()


def test_convert_tools_to_description_object_details():
tools: list[ChatCompletionToolParam] = [
{
"type": "function",
"function": {
"name": "object_tool",
"description": "Test object parameter",
"parameters": {
"type": "object",
"properties": {
"config": {
"type": "object",
"description": "Configuration payload",
"properties": {
"name": {
"type": "string",
"description": "Friendly name",
},
"thresholds": {
"type": "array",
"description": "Threshold list",
"items": {"type": "number"},
},
},
"required": ["name"],
"additionalProperties": {
"type": "string",
"description": "Extra properties",
},
}
},
"required": ["config"],
},
},
}
]

result = convert_tools_to_description(tools)

expected = textwrap.dedent(
"""\
---- BEGIN FUNCTION #1: object_tool ----
Description: Test object parameter
Parameters:
(1) config (object, required): Configuration payload
Object properties:
- name (string, required): Friendly name
- thresholds (array[number], optional): Threshold list
Array items:
Type: number
Additional properties allowed: string
---- END FUNCTION #1 ----
"""
)

assert result.strip() == expected.strip()
Loading