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 de713b80f4..a41e088f58 100644 --- a/openhands-sdk/openhands/sdk/llm/mixins/fn_call_converter.py +++ b/openhands-sdk/openhands/sdk/llm/mixins/fn_call_converter.py @@ -574,6 +574,16 @@ def _extract_and_validate_params( return params +# Kimi K3 can emit a wrapper with the tool name on its own line +# instead of the prompted opener (see issue #4540): +# file_editor +# view +# +_TOOL_CALL_WRAPPER_PATTERN = re.compile( + r"\s*([A-Za-z_][\w.-]*)\s*\n(?=\s* str: """Clean up model-specific formatting before parsing function calls. @@ -589,6 +599,13 @@ def _preprocess_model_output(content: str) -> str: content = re.sub(r"\s*(?= when it appears right after content = re.sub(r"(?<=)\s*", "", content) + + if "\n", content, count=1 + ) + if replaced: + content = re.sub(r"(\s*)\Z", r"\1", content, count=1) return content diff --git a/tests/sdk/llm/test_llm_fncall_converter.py b/tests/sdk/llm/test_llm_fncall_converter.py index 71aa0e3029..0bd18a97b5 100644 --- a/tests/sdk/llm/test_llm_fncall_converter.py +++ b/tests/sdk/llm/test_llm_fncall_converter.py @@ -960,3 +960,66 @@ def test_security_params_excluded_when_flag_is_false(): system_content = result[0]["content"] assert "" not in system_content assert "" not in system_content + + +@pytest.mark.parametrize( + "content", + [ + ( + "I'll list the files.\n" + "terminal\n" + "ls\n" + "" + ), + "terminal\nls", + ], +) +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 = ( + "terminal\n" + "printf ''\n" + "" + ) + 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 ''"} + + +def test_tool_call_mention_in_plain_text_is_not_converted(): + content = ( + "Here is how you write a tool call:\n\n" + " 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")