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")