Skip to content
Open
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
17 changes: 17 additions & 0 deletions openhands-sdk/openhands/sdk/llm/mixins/fn_call_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,16 @@ def _extract_and_validate_params(
return params


# Kimi K3 can emit a <tool_call> wrapper with the tool name on its own line
# instead of the prompted <function=NAME> opener (see issue #4540):
# <tool_call>file_editor
# <parameter=command>view</parameter>
# </tool_call>
_TOOL_CALL_WRAPPER_PATTERN = re.compile(
r"<tool_call>\s*([A-Za-z_][\w.-]*)\s*\n(?=\s*<parameter=)"
)


def _preprocess_model_output(content: str) -> str:
"""Clean up model-specific formatting before parsing function calls.

Expand All @@ -589,6 +599,13 @@ def _preprocess_model_output(content: str) -> str:
content = re.sub(r"<tool_call>\s*(?=<function=)", "", content)
# Strip </tool_call> when it appears right after </function>
content = re.sub(r"(?<=</function>)\s*</tool_call>", "", content)

if "<function=" not in content:
content, replaced = _TOOL_CALL_WRAPPER_PATTERN.subn(
r"<function=\1>\n", content, count=1
)
if replaced:
content = re.sub(r"</tool_call>(\s*)\Z", r"</function>\1", content, count=1)
return content


Expand Down
63 changes: 63 additions & 0 deletions tests/sdk/llm/test_llm_fncall_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -960,3 +960,66 @@ def test_security_params_excluded_when_flag_is_false():
system_content = result[0]["content"]
assert "<parameter=security_risk>" not in system_content
assert "<parameter=summary>" not in system_content


@pytest.mark.parametrize(
"content",
[
(
"I'll list the files.\n"
"<tool_call>terminal\n"
"<parameter=command>ls</parameter>\n"
"</tool_call>"
),
"<tool_call>terminal\n<parameter=command>ls</parameter>",
],
)
def test_tool_call_wrapper_normalized_to_function_call(content):
messages = [
{"role": "user", "content": "run ls"},
{"role": "assistant", "content": content},
]

fncall_messages = convert_non_fncall_messages_to_fncall_messages(
messages, FNCALL_TOOLS
)

tool_call = fncall_messages[1]["tool_calls"][0]["function"]
assert tool_call["name"] == "terminal"
assert json.loads(tool_call["arguments"]) == {"command": "ls"}


def test_tool_call_wrapper_preserves_closing_tag_in_parameter():
content = (
"<tool_call>terminal\n"
"<parameter=command>printf '</tool_call>'</parameter>\n"
"</tool_call>"
)
messages = [
{"role": "user", "content": "print the closing tag"},
{"role": "assistant", "content": content},
]

fncall_messages = convert_non_fncall_messages_to_fncall_messages(
messages, FNCALL_TOOLS
)

tool_call = fncall_messages[1]["tool_calls"][0]["function"]
assert json.loads(tool_call["arguments"]) == {"command": "printf '</tool_call>'"}


def test_tool_call_mention_in_plain_text_is_not_converted():
content = (
"Here is how you write a tool call:\n\n"
"<tool_call> is the opening tag, followed by parameters."
)
messages = [
{"role": "user", "content": "explain the format"},
{"role": "assistant", "content": content},
]

fncall_messages = convert_non_fncall_messages_to_fncall_messages(
messages, FNCALL_TOOLS
)

assert not fncall_messages[1].get("tool_calls")