Skip to content

Commit 377ea66

Browse files
ryanhoangtenystopenhands-agent
authored
Handle more nested field schema formatting for tool description (#1001)
Co-authored-by: Engel Nyst <engel.nyst@gmail.com> Co-authored-by: openhands <openhands@all-hands.dev>
1 parent 3addb5a commit 377ea66

File tree

2 files changed

+321
-42
lines changed

2 files changed

+321
-42
lines changed

openhands-sdk/openhands/sdk/llm/mixins/fn_call_converter.py

Lines changed: 162 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import re
1212
import sys
1313
from collections.abc import Iterable
14-
from typing import Literal, NotRequired, TypedDict, cast
14+
from typing import Any, Literal, NotRequired, TypedDict, cast
1515

1616
from litellm import ChatCompletionToolParam, ChatCompletionToolParamFunctionChunk
1717

@@ -41,6 +41,11 @@ class TextPart(TypedDict):
4141
TASK_TRACKER_TOOL_NAME = "task_tracker"
4242

4343
# Inspired by: https://docs.together.ai/docs/llama-3-function-calling#function-calling-w-llama-31-70b
44+
MISSING_DESCRIPTION_PLACEHOLDER = "No description provided"
45+
SCHEMA_INDENT_STEP = 2
46+
SCHEMA_UNION_KEYS = ("anyOf", "oneOf", "allOf")
47+
48+
4449
system_message_suffix_TEMPLATE = """
4550
You have access to the following functions:
4651
@@ -487,6 +492,155 @@ def convert_tool_call_to_string(tool_call: dict) -> str:
487492
return ret
488493

489494

495+
def _summarize_schema_type(schema: object | None) -> str:
496+
"""
497+
Capture array, union, enum, and nested type info.
498+
"""
499+
if not isinstance(schema, dict):
500+
return "unknown" if schema is None else str(schema)
501+
502+
for key in SCHEMA_UNION_KEYS:
503+
if key in schema:
504+
return " or ".join(_summarize_schema_type(option) for option in schema[key])
505+
506+
schema_type = schema.get("type")
507+
if isinstance(schema_type, list):
508+
return " or ".join(str(t) for t in schema_type)
509+
if schema_type == "array":
510+
items = schema.get("items")
511+
if isinstance(items, list):
512+
item_types = ", ".join(_summarize_schema_type(item) for item in items)
513+
return f"array[{item_types}]"
514+
if isinstance(items, dict):
515+
return f"array[{_summarize_schema_type(items)}]"
516+
return "array"
517+
if schema_type:
518+
return str(schema_type)
519+
if "enum" in schema:
520+
return "enum"
521+
return "unknown"
522+
523+
524+
def _indent(indent: int) -> str:
525+
return " " * indent
526+
527+
528+
def _nested_indent(indent: int, levels: int = 1) -> int:
529+
return indent + SCHEMA_INDENT_STEP * levels
530+
531+
532+
def _get_description(schema: dict[str, object] | None) -> str:
533+
"""
534+
Extract description from schema, or return placeholder if missing.
535+
"""
536+
if not isinstance(schema, dict):
537+
return MISSING_DESCRIPTION_PLACEHOLDER
538+
description = schema.get("description")
539+
if isinstance(description, str) and description.strip():
540+
return description
541+
return MISSING_DESCRIPTION_PLACEHOLDER
542+
543+
544+
def _format_union_details(schema: dict[str, object], indent: int) -> list[str] | None:
545+
for key in SCHEMA_UNION_KEYS:
546+
options = schema.get(key)
547+
if not isinstance(options, list):
548+
continue
549+
lines = [f"{_indent(indent)}{key} options:"]
550+
for option in options:
551+
option_type = _summarize_schema_type(option)
552+
option_line = f"{_indent(_nested_indent(indent))}- {option_type}"
553+
option_line += (
554+
f": {_get_description(option if isinstance(option, dict) else None)}"
555+
)
556+
lines.append(option_line)
557+
lines.extend(_format_schema_detail(option, _nested_indent(indent, 2)))
558+
return lines
559+
return None
560+
561+
562+
def _format_array_details(schema: dict[str, object], indent: int) -> list[str]:
563+
lines = [f"{_indent(indent)}Array items:"]
564+
items = schema.get("items")
565+
if isinstance(items, list):
566+
for index, item_schema in enumerate(items):
567+
item_type = _summarize_schema_type(item_schema)
568+
lines.append(
569+
f"{_indent(_nested_indent(indent))}- index {index}: {item_type}"
570+
)
571+
lines.extend(_format_schema_detail(item_schema, _nested_indent(indent, 2)))
572+
elif isinstance(items, dict):
573+
lines.append(
574+
f"{_indent(_nested_indent(indent))}Type: {_summarize_schema_type(items)}"
575+
)
576+
lines.extend(_format_schema_detail(items, _nested_indent(indent, 2)))
577+
else:
578+
lines.append(f"{_indent(_nested_indent(indent))}Type: unknown")
579+
return lines
580+
581+
582+
def _format_additional_properties(
583+
additional_props: object | None, indent: int
584+
) -> list[str]:
585+
if isinstance(additional_props, dict):
586+
line = (
587+
f"{_indent(indent)}Additional properties allowed: "
588+
f"{_summarize_schema_type(additional_props)}"
589+
)
590+
lines = [line]
591+
lines.extend(_format_schema_detail(additional_props, _nested_indent(indent)))
592+
return lines
593+
if additional_props is True:
594+
return [f"{_indent(indent)}Additional properties allowed."]
595+
if additional_props is False:
596+
return [f"{_indent(indent)}Additional properties not allowed."]
597+
return []
598+
599+
600+
def _format_object_details(schema: dict[str, Any], indent: int) -> list[str]:
601+
lines: list[str] = []
602+
properties = schema.get("properties", {})
603+
required = set(schema.get("required", []))
604+
if isinstance(properties, dict) and properties:
605+
lines.append(f"{_indent(indent)}Object properties:")
606+
for name, prop in properties.items():
607+
prop_type = _summarize_schema_type(prop)
608+
required_flag = "required" if name in required else "optional"
609+
prop_desc = _get_description(prop if isinstance(prop, dict) else None)
610+
lines.append(
611+
f"{_indent(_nested_indent(indent))}- {name} ({prop_type},"
612+
f" {required_flag}): {prop_desc}"
613+
)
614+
lines.extend(_format_schema_detail(prop, _nested_indent(indent, 2)))
615+
lines.extend(
616+
_format_additional_properties(schema.get("additionalProperties"), indent)
617+
)
618+
return lines
619+
620+
621+
def _format_schema_detail(schema: object | None, indent: int = 4) -> list[str]:
622+
"""Recursively describe arrays, objects, unions, and additional properties."""
623+
if not isinstance(schema, dict):
624+
return []
625+
626+
union_lines = _format_union_details(schema, indent)
627+
if union_lines is not None:
628+
return union_lines
629+
630+
schema_type = schema.get("type")
631+
if isinstance(schema_type, list):
632+
allowed_types = ", ".join(str(t) for t in schema_type)
633+
return [f"{_indent(indent)}Allowed types: {allowed_types}"]
634+
635+
if schema_type == "array":
636+
return _format_array_details(schema, indent)
637+
638+
if schema_type == "object":
639+
return _format_object_details(schema, indent)
640+
641+
return []
642+
643+
490644
def convert_tools_to_description(tools: list[ChatCompletionToolParam]) -> str:
491645
ret = ""
492646
for i, tool in enumerate(tools):
@@ -504,15 +658,14 @@ def convert_tools_to_description(tools: list[ChatCompletionToolParam]) -> str:
504658
required_params = set(fn["parameters"].get("required", []))
505659

506660
for j, (param_name, param_info) in enumerate(properties.items()):
507-
# Indicate required/optional in parentheses with type
508661
is_required = param_name in required_params
509662
param_status = "required" if is_required else "optional"
510-
param_type = param_info.get("type", "string")
663+
param_type = _summarize_schema_type(param_info)
511664

512-
# Get parameter description
513-
desc = param_info.get("description", "No description provided")
665+
desc = _get_description(
666+
param_info if isinstance(param_info, dict) else None
667+
)
514668

515-
# Handle enum values if present
516669
if "enum" in param_info:
517670
enum_values = ", ".join(f"`{v}`" for v in param_info["enum"])
518671
desc += f"\nAllowed values: [{enum_values}]"
@@ -521,34 +674,10 @@ def convert_tools_to_description(tools: list[ChatCompletionToolParam]) -> str:
521674
f" ({j + 1}) {param_name} ({param_type}, {param_status}): {desc}\n"
522675
)
523676

524-
# Handle nested structure for array/object types
525-
if param_type == "array" and "items" in param_info:
526-
items = param_info["items"]
527-
if items.get("type") == "object" and "properties" in items:
528-
ret += " task_list array item structure:\n"
529-
item_properties = items["properties"]
530-
item_required = set(items.get("required", []))
531-
for k, (item_param_name, item_param_info) in enumerate(
532-
item_properties.items()
533-
):
534-
item_is_required = item_param_name in item_required
535-
item_status = "required" if item_is_required else "optional"
536-
item_type = item_param_info.get("type", "string")
537-
item_desc = item_param_info.get(
538-
"description", "No description provided"
539-
)
540-
541-
# Handle enum values for nested items
542-
if "enum" in item_param_info:
543-
item_enum_values = ", ".join(
544-
f"`{v}`" for v in item_param_info["enum"]
545-
)
546-
item_desc += f" Allowed values: [{item_enum_values}]"
677+
detail_lines = _format_schema_detail(param_info, indent=6)
678+
if detail_lines:
679+
ret += "\n".join(detail_lines) + "\n"
547680

548-
ret += (
549-
f" - {item_param_name} ({item_type}, "
550-
f"{item_status}): {item_desc}\n"
551-
)
552681
else:
553682
ret += "No parameters are required for this function.\n"
554683

0 commit comments

Comments
 (0)