From b311ae6e156ed9188affe5207bde481e7a35c000 Mon Sep 17 00:00:00 2001 From: Engel Nyst Date: Thu, 21 Aug 2025 19:03:20 +0200 Subject: [PATCH] fix: normalize malformed tags (Qwen3) (#10539) --- openhands/llm/fn_call_converter.py | 21 +++++++++- tests/unit/llm/test_llm_fncall_converter.py | 46 +++++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/openhands/llm/fn_call_converter.py b/openhands/llm/fn_call_converter.py index d0e2b35ae8..7de8824516 100644 --- a/openhands/llm/fn_call_converter.py +++ b/openhands/llm/fn_call_converter.py @@ -705,6 +705,25 @@ def _fix_stopword(content: str) -> str: return content +def _normalize_parameter_tags(fn_body: str) -> str: + """Normalize malformed parameter tags to the canonical format. + + Some models occasionally emit malformed parameter tags like: + + instead of the correct: + str_replace + + This function rewrites the malformed form into the correct one to allow + downstream parsing to succeed. + """ + # Replace '' with 'value' + return re.sub( + r'', + r'\2', + fn_body, + ) + + def convert_non_fncall_messages_to_fncall_messages( messages: list[dict], tools: list[ChatCompletionToolParam], @@ -852,7 +871,7 @@ def convert_non_fncall_messages_to_fncall_messages( if fn_match: fn_name = fn_match.group(1) - fn_body = fn_match.group(2) + fn_body = _normalize_parameter_tags(fn_match.group(2)) matching_tool = next( ( tool['function'] diff --git a/tests/unit/llm/test_llm_fncall_converter.py b/tests/unit/llm/test_llm_fncall_converter.py index d29cd0b958..ff4b7961ef 100644 --- a/tests/unit/llm/test_llm_fncall_converter.py +++ b/tests/unit/llm/test_llm_fncall_converter.py @@ -96,6 +96,52 @@ FNCALL_TOOLS: list[ChatCompletionToolParam] = [ ] +def test_malformed_parameter_parsing_recovery(): + """Ensure we can recover when models emit malformed parameter tags like . + + This simulates a tool call to str_replace_editor where the 'command' parameter is malformed. + """ + from openhands.llm.fn_call_converter import ( + convert_non_fncall_messages_to_fncall_messages, + ) + + # Construct an assistant message with malformed parameter tag for 'command' + assistant_message = { + 'role': 'assistant', + 'content': ( + '\n' + '\n' # malformed form + '/repo/app.py\n' + 'foo\n' + 'bar\n' + '' + ), + } + + messages = [ + {'role': 'system', 'content': 'test'}, + {'role': 'user', 'content': 'do edit'}, + assistant_message, + ] + + converted = convert_non_fncall_messages_to_fncall_messages(messages, FNCALL_TOOLS) + + # The last message should be assistant with a parsed tool call + last = converted[-1] + assert last['role'] == 'assistant' + assert 'tool_calls' in last and len(last['tool_calls']) == 1 + tool_call = last['tool_calls'][0] + assert tool_call['type'] == 'function' + assert tool_call['function']['name'] == 'str_replace_editor' + + # Arguments must be a valid JSON with command=str_replace and proper params + args = json.loads(tool_call['function']['arguments']) + assert args['command'] == 'str_replace' + assert args['path'] == '/repo/app.py' + assert args['old_str'] == 'foo' + assert args['new_str'] == 'bar' + + def test_convert_tools_to_description(): formatted_tools = convert_tools_to_description(FNCALL_TOOLS) print(formatted_tools)