From 563781bd9e7179870de9083dac73a79c64fc723a Mon Sep 17 00:00:00 2001 From: Hoang Tran Date: Fri, 7 Nov 2025 09:25:24 +0000 Subject: [PATCH 01/12] remote empty text content --- openhands-sdk/openhands/sdk/llm/message.py | 37 ++++++++++++++++++++ tests/sdk/llm/test_message.py | 39 ++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/openhands-sdk/openhands/sdk/llm/message.py b/openhands-sdk/openhands/sdk/llm/message.py index 8ba235a557..046254a573 100644 --- a/openhands-sdk/openhands/sdk/llm/message.py +++ b/openhands-sdk/openhands/sdk/llm/message.py @@ -269,6 +269,14 @@ def to_chat_dict(self) -> dict[str, Any]: # Assistant function_call(s) if self.role == "assistant" and self.tool_calls: message_dict["tool_calls"] = [tc.to_chat_dict() for tc in self.tool_calls] + if "content" in message_dict: + normalized_content = self._normalize_tool_call_content( + message_dict["content"] + ) + if normalized_content is None: + message_dict.pop("content", None) + else: + message_dict["content"] = normalized_content # Tool result (observation) threading if self.role == "tool" and self.tool_call_id is not None: @@ -331,6 +339,35 @@ def _list_serializer(self) -> dict[str, Any]: # tool call keys are added in to_chat_dict to centralize behavior return message_dict + def _normalize_tool_call_content(self, content: Any) -> Any | None: + """Remove empty text payloads from assistant tool call messages.""" + if isinstance(content, str): + if content.strip() == "": + return None + return content + + if isinstance(content, list): + normalized: list[Any] = [] + for item in content: + if not isinstance(item, dict): + normalized.append(item) + continue + + if item.get("type") == "text": + text_value = item.get("text", "") + if isinstance(text_value, str): + if text_value.strip() == "": + continue + else: + if str(text_value).strip() == "": + continue + + normalized.append(item) + + return normalized if normalized else None + + return content + def to_responses_value(self, *, vision_enabled: bool) -> str | list[dict[str, Any]]: """Return serialized form. diff --git a/tests/sdk/llm/test_message.py b/tests/sdk/llm/test_message.py index 99ad27fc2b..2cdb1cd4ec 100644 --- a/tests/sdk/llm/test_message.py +++ b/tests/sdk/llm/test_message.py @@ -142,6 +142,45 @@ def test_message_with_tool_calls(): assert result["tool_calls"][0]["function"]["arguments"] == '{"arg": "value"}' +def test_message_tool_calls_drop_empty_string_content(): + """Assistant tool calls with no text should not include empty content strings.""" + from openhands.sdk.llm.message import Message, MessageToolCall + + tool_call = MessageToolCall( + id="call_empty", + name="test_function", + arguments="{}", + origin="completion", + ) + + message = Message(role="assistant", content=[], tool_calls=[tool_call]) + + result = message.to_chat_dict() + assert "content" not in result + + +def test_message_tool_calls_strip_blank_list_content(): + """List-serialized tool call messages should drop blank text content blocks.""" + from openhands.sdk.llm.message import Message, MessageToolCall, TextContent + + tool_call = MessageToolCall( + id="call_blank_list", + name="test_function", + arguments="{}", + origin="completion", + ) + + message = Message( + role="assistant", + content=[TextContent(text="")], + tool_calls=[tool_call], + function_calling_enabled=True, + ) + + result = message.to_chat_dict() + assert "content" not in result + + def test_message_from_llm_chat_message_function_role_error(): """Test Message.from_llm_chat_message with function role raises error.""" from litellm.types.utils import Message as LiteLLMMessage From a1d7f6df3bf347508756b3d4c7288a3f972663ad Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 10 Nov 2025 14:49:18 +0000 Subject: [PATCH 02/12] Add kimi-k2-thinking to regression test workflow Co-authored-by: openhands --- .github/workflows/integration-runner.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/integration-runner.yml b/.github/workflows/integration-runner.yml index d512251c44..676856d556 100644 --- a/.github/workflows/integration-runner.yml +++ b/.github/workflows/integration-runner.yml @@ -67,6 +67,10 @@ jobs: run-suffix: deepseek_run llm-config: model: litellm_proxy/deepseek/deepseek-chat + - name: Kimi K2 Thinking + run-suffix: kimi_k2_run + llm-config: + model: litellm_proxy/kimi-k2-thinking steps: - name: Checkout repository uses: actions/checkout@v5 From 0d874268b659950246033c1f6a32f59caa50bde8 Mon Sep 17 00:00:00 2001 From: enyst Date: Mon, 10 Nov 2025 15:11:59 +0000 Subject: [PATCH 03/12] llm: remove empty assistant content when tool_calls present via helper - Rename helper to _remove_content_if_empty (more explicit than 'normalize') - Do removal in-place inside helper and call it from to_chat_dict This keeps provider compatibility (e.g., kimi-k2-thinking rejects empty text blocks) while leaving other message flows unchanged. Co-authored-by: openhands --- openhands-sdk/openhands/sdk/llm/message.py | 36 +++++++++++++--------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/openhands-sdk/openhands/sdk/llm/message.py b/openhands-sdk/openhands/sdk/llm/message.py index 046254a573..7bb9cd1dd6 100644 --- a/openhands-sdk/openhands/sdk/llm/message.py +++ b/openhands-sdk/openhands/sdk/llm/message.py @@ -269,14 +269,7 @@ def to_chat_dict(self) -> dict[str, Any]: # Assistant function_call(s) if self.role == "assistant" and self.tool_calls: message_dict["tool_calls"] = [tc.to_chat_dict() for tc in self.tool_calls] - if "content" in message_dict: - normalized_content = self._normalize_tool_call_content( - message_dict["content"] - ) - if normalized_content is None: - message_dict.pop("content", None) - else: - message_dict["content"] = normalized_content + self._remove_content_if_empty(message_dict) # Tool result (observation) threading if self.role == "tool" and self.tool_call_id is not None: @@ -339,12 +332,23 @@ def _list_serializer(self) -> dict[str, Any]: # tool call keys are added in to_chat_dict to centralize behavior return message_dict - def _normalize_tool_call_content(self, content: Any) -> Any | None: - """Remove empty text payloads from assistant tool call messages.""" + def _remove_content_if_empty(self, message_dict: dict[str, Any]) -> None: + """Remove empty text content entries from assistant tool-call messages. + + Mutates the provided message_dict in-place: + - If content is a string of only whitespace, drop the 'content' key + - If content is a list, remove any text items with empty text; if the list + becomes empty, drop the 'content' key + """ + if "content" not in message_dict: + return + + content = message_dict["content"] + if isinstance(content, str): if content.strip() == "": - return None - return content + message_dict.pop("content", None) + return if isinstance(content, list): normalized: list[Any] = [] @@ -364,9 +368,13 @@ def _normalize_tool_call_content(self, content: Any) -> Any | None: normalized.append(item) - return normalized if normalized else None + if normalized: + message_dict["content"] = normalized + else: + message_dict.pop("content", None) + return - return content + # Any other content shape is left as-is def to_responses_value(self, *, vision_enabled: bool) -> str | list[dict[str, Any]]: """Return serialized form. From 5f413c90bb428e99fc8c46b53f7751f7a397cf6e Mon Sep 17 00:00:00 2001 From: Hoang Tran Date: Mon, 10 Nov 2025 15:37:53 +0000 Subject: [PATCH 04/12] fix model name --- .github/workflows/integration-runner.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/integration-runner.yml b/.github/workflows/integration-runner.yml index 676856d556..5ff9f80d89 100644 --- a/.github/workflows/integration-runner.yml +++ b/.github/workflows/integration-runner.yml @@ -70,7 +70,7 @@ jobs: - name: Kimi K2 Thinking run-suffix: kimi_k2_run llm-config: - model: litellm_proxy/kimi-k2-thinking + model: litellm_proxy/moonshot/kimi-k2-thinking steps: - name: Checkout repository uses: actions/checkout@v5 From c3ec892a352463e4d0e54946199e541a97964b34 Mon Sep 17 00:00:00 2001 From: Hoang Tran Date: Mon, 10 Nov 2025 15:47:55 +0000 Subject: [PATCH 05/12] fix regex --- tests/integration/utils/consolidate_json_results.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/utils/consolidate_json_results.py b/tests/integration/utils/consolidate_json_results.py index e1c5a6107e..a1dce35640 100644 --- a/tests/integration/utils/consolidate_json_results.py +++ b/tests/integration/utils/consolidate_json_results.py @@ -87,7 +87,7 @@ def extract_matrix_run_suffix(full_run_suffix: str) -> str | None: # Pattern to match the matrix run suffix # Look for pattern: _{7_hex_chars}_{matrix_run_suffix}_N{number}_ # The commit hash is always 7 hex characters - pattern = r"_[a-f0-9]{7}_([^_]+(?:_[^_]+)*_run)_N\d+_" + pattern = r"_[a-f0-9]{7}_([\w\-]+(?:_[\w\-]+)*_run)_N\d+_" match = re.search(pattern, full_run_suffix) if match: From c1876344561f8651b30e5d0ac6aa9e09cf48d178 Mon Sep 17 00:00:00 2001 From: Hoang Tran Date: Mon, 10 Nov 2025 15:54:56 +0000 Subject: [PATCH 06/12] fix regex --- tests/integration/utils/consolidate_json_results.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/utils/consolidate_json_results.py b/tests/integration/utils/consolidate_json_results.py index a1dce35640..3ef7d31028 100644 --- a/tests/integration/utils/consolidate_json_results.py +++ b/tests/integration/utils/consolidate_json_results.py @@ -87,7 +87,7 @@ def extract_matrix_run_suffix(full_run_suffix: str) -> str | None: # Pattern to match the matrix run suffix # Look for pattern: _{7_hex_chars}_{matrix_run_suffix}_N{number}_ # The commit hash is always 7 hex characters - pattern = r"_[a-f0-9]{7}_([\w\-]+(?:_[\w\-]+)*_run)_N\d+_" + pattern = r"_[a-f0-9]{7}_([\w\-]+_run)_N\d+_" match = re.search(pattern, full_run_suffix) if match: From c4a7a75f9e932e13d73da3d6fc37fe2e3d140b6e Mon Sep 17 00:00:00 2001 From: Hoang Tran Date: Mon, 10 Nov 2025 16:27:11 +0000 Subject: [PATCH 07/12] test --- .../utils/consolidate_json_results.py | 34 ++++++++++++++----- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/tests/integration/utils/consolidate_json_results.py b/tests/integration/utils/consolidate_json_results.py index 3ef7d31028..acecb542d1 100644 --- a/tests/integration/utils/consolidate_json_results.py +++ b/tests/integration/utils/consolidate_json_results.py @@ -82,18 +82,34 @@ def extract_matrix_run_suffix(full_run_suffix: str) -> str | None: - litellm_proxy_openai_gpt_5_mini_0dd44e1_gpt5_mini_run_N7_20251006_183117 -> gpt5_mini_run """ # noqa: E501 - import re - # Pattern to match the matrix run suffix - # Look for pattern: _{7_hex_chars}_{matrix_run_suffix}_N{number}_ - # The commit hash is always 7 hex characters - pattern = r"_[a-f0-9]{7}_([\w\-]+_run)_N\d+_" - match = re.search(pattern, full_run_suffix) + if not full_run_suffix: + return None + + try: + # Separate the trailing `_N{count}_{timestamp}` section + prefix, _ = full_run_suffix.rsplit("_N", 1) + except ValueError: + return None + + tokens = prefix.split("_") + if len(tokens) < 2: + return None + + for idx in range(len(tokens) - 1, -1, -1): + token = tokens[idx] + token_lower = token.lower() + if len(token_lower) >= 7 and all( + ch in "0123456789abcdef" for ch in token_lower + ): + matrix_tokens = tokens[idx + 1 :] + if not matrix_tokens: + continue - if match: - return match.group(1) + matrix_run_suffix = "_".join(matrix_tokens) + if matrix_run_suffix.endswith("_run"): + return matrix_run_suffix - # Fallback: if pattern doesn't match, return None return None From d218af76904cffadb3d327323a64ec192465e17d Mon Sep 17 00:00:00 2001 From: Hoang Tran Date: Mon, 10 Nov 2025 16:56:22 +0000 Subject: [PATCH 08/12] test --- .../utils/consolidate_json_results.py | 34 +++++-------------- 1 file changed, 9 insertions(+), 25 deletions(-) diff --git a/tests/integration/utils/consolidate_json_results.py b/tests/integration/utils/consolidate_json_results.py index acecb542d1..e1c5a6107e 100644 --- a/tests/integration/utils/consolidate_json_results.py +++ b/tests/integration/utils/consolidate_json_results.py @@ -82,34 +82,18 @@ def extract_matrix_run_suffix(full_run_suffix: str) -> str | None: - litellm_proxy_openai_gpt_5_mini_0dd44e1_gpt5_mini_run_N7_20251006_183117 -> gpt5_mini_run """ # noqa: E501 + import re - if not full_run_suffix: - return None - - try: - # Separate the trailing `_N{count}_{timestamp}` section - prefix, _ = full_run_suffix.rsplit("_N", 1) - except ValueError: - return None - - tokens = prefix.split("_") - if len(tokens) < 2: - return None - - for idx in range(len(tokens) - 1, -1, -1): - token = tokens[idx] - token_lower = token.lower() - if len(token_lower) >= 7 and all( - ch in "0123456789abcdef" for ch in token_lower - ): - matrix_tokens = tokens[idx + 1 :] - if not matrix_tokens: - continue + # Pattern to match the matrix run suffix + # Look for pattern: _{7_hex_chars}_{matrix_run_suffix}_N{number}_ + # The commit hash is always 7 hex characters + pattern = r"_[a-f0-9]{7}_([^_]+(?:_[^_]+)*_run)_N\d+_" + match = re.search(pattern, full_run_suffix) - matrix_run_suffix = "_".join(matrix_tokens) - if matrix_run_suffix.endswith("_run"): - return matrix_run_suffix + if match: + return match.group(1) + # Fallback: if pattern doesn't match, return None return None From 0bc52b242ad537646fe7ae359103181e8a556a92 Mon Sep 17 00:00:00 2001 From: Hoang Tran Date: Mon, 10 Nov 2025 16:57:04 +0000 Subject: [PATCH 09/12] test --- .github/workflows/integration-runner.yml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/integration-runner.yml b/.github/workflows/integration-runner.yml index 5ff9f80d89..e2a61f4f70 100644 --- a/.github/workflows/integration-runner.yml +++ b/.github/workflows/integration-runner.yml @@ -54,15 +54,15 @@ jobs: matrix: python-version: ['3.12'] job-config: - - name: Claude Sonnet 4.5 - run-suffix: sonnet_run - llm-config: - model: litellm_proxy/claude-sonnet-4-5-20250929 - - name: GPT-5 Mini 2025-08-07 - run-suffix: gpt5_mini_run - llm-config: - model: litellm_proxy/gpt-5-mini-2025-08-07 - temperature: 1.0 + # - name: Claude Sonnet 4.5 + # run-suffix: sonnet_run + # llm-config: + # model: litellm_proxy/claude-sonnet-4-5-20250929 + # - name: GPT-5 Mini 2025-08-07 + # run-suffix: gpt5_mini_run + # llm-config: + # model: litellm_proxy/gpt-5-mini-2025-08-07 + # temperature: 1.0 - name: Deepseek Chat run-suffix: deepseek_run llm-config: From 2fb7339022f686811432533b4580244cc18d94e6 Mon Sep 17 00:00:00 2001 From: Hoang Tran Date: Mon, 10 Nov 2025 17:07:39 +0000 Subject: [PATCH 10/12] test --- .github/workflows/integration-runner.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/integration-runner.yml b/.github/workflows/integration-runner.yml index e2a61f4f70..6a1dbad11a 100644 --- a/.github/workflows/integration-runner.yml +++ b/.github/workflows/integration-runner.yml @@ -21,7 +21,7 @@ env: jobs: post-initial-comment: - if: github.event_name == 'pull_request_target' && github.event.label.name == 'integration-test' + if: github.event_name == 'pull_request' && github.event.label.name == 'integration-test' runs-on: ubuntu-latest permissions: pull-requests: write From 6d11aa2b40ae00bbb2c5094cf33f9bb4597ce994 Mon Sep 17 00:00:00 2001 From: Hoang Tran Date: Mon, 10 Nov 2025 17:14:59 +0000 Subject: [PATCH 11/12] revert --- .github/workflows/integration-runner.yml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/integration-runner.yml b/.github/workflows/integration-runner.yml index 6a1dbad11a..5ff9f80d89 100644 --- a/.github/workflows/integration-runner.yml +++ b/.github/workflows/integration-runner.yml @@ -21,7 +21,7 @@ env: jobs: post-initial-comment: - if: github.event_name == 'pull_request' && github.event.label.name == 'integration-test' + if: github.event_name == 'pull_request_target' && github.event.label.name == 'integration-test' runs-on: ubuntu-latest permissions: pull-requests: write @@ -54,15 +54,15 @@ jobs: matrix: python-version: ['3.12'] job-config: - # - name: Claude Sonnet 4.5 - # run-suffix: sonnet_run - # llm-config: - # model: litellm_proxy/claude-sonnet-4-5-20250929 - # - name: GPT-5 Mini 2025-08-07 - # run-suffix: gpt5_mini_run - # llm-config: - # model: litellm_proxy/gpt-5-mini-2025-08-07 - # temperature: 1.0 + - name: Claude Sonnet 4.5 + run-suffix: sonnet_run + llm-config: + model: litellm_proxy/claude-sonnet-4-5-20250929 + - name: GPT-5 Mini 2025-08-07 + run-suffix: gpt5_mini_run + llm-config: + model: litellm_proxy/gpt-5-mini-2025-08-07 + temperature: 1.0 - name: Deepseek Chat run-suffix: deepseek_run llm-config: From e5863094d579d7a36c8233bd64a69d26665a565b Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 10 Nov 2025 19:53:41 +0000 Subject: [PATCH 12/12] Raise exception for non-string text values in message content Instead of silently converting non-string text values to strings, raise a ValueError when a text content item has a non-string text value. This ensures we catch invalid message states early rather than attempting to handle them gracefully. Co-authored-by: openhands --- openhands-sdk/openhands/sdk/llm/message.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/openhands-sdk/openhands/sdk/llm/message.py b/openhands-sdk/openhands/sdk/llm/message.py index 7bb9cd1dd6..fb70135ac2 100644 --- a/openhands-sdk/openhands/sdk/llm/message.py +++ b/openhands-sdk/openhands/sdk/llm/message.py @@ -363,8 +363,10 @@ def _remove_content_if_empty(self, message_dict: dict[str, Any]) -> None: if text_value.strip() == "": continue else: - if str(text_value).strip() == "": - continue + raise ValueError( + f"Text content item has non-string text value: " + f"{text_value!r}" + ) normalized.append(item)