diff --git a/openhands-sdk/openhands/sdk/conversation/title_utils.py b/openhands-sdk/openhands/sdk/conversation/title_utils.py index 4e98dd015e..c8bd6781a7 100644 --- a/openhands-sdk/openhands/sdk/conversation/title_utils.py +++ b/openhands-sdk/openhands/sdk/conversation/title_utils.py @@ -1,5 +1,6 @@ """Utility functions for generating conversation titles.""" +import re from collections.abc import Callable, Sequence from openhands.sdk.event import MessageEvent @@ -11,6 +12,43 @@ logger = get_logger(__name__) +# Conservative peel of a leading 思考 reasoning block. Some providers +# (e.g. Qwen3 behind Nebius) return reasoning inline in `content` as +# `思考reasoning…思考` instead of the normalized `reasoning_content` +# field, which would otherwise leak into the autogenerated title. +# The peel only targets a leading block so mid-text literal `思考` +# occurrences in a legitimate title are preserved. +_THINK_BLOCK_RE = re.compile(r"^\s*思考[\s\S]*?思考\s*", re.DOTALL) + + +def _strip_leading_think_block(text: str) -> str: + """Remove a leading ``思考`` block from ``text`` if present. + + The conservative peel handles two shapes: + + * A closed block ``思考…思考`` at the start of the response is + stripped and the remainder is returned. + * An unterminated block (the response begins with ``思考`` and + never closes) consumes the entire response, returning ``""`` so + the LLM title path yields no title and the existing + user-message truncation fallback runs. + + A leading ``思考`` substring that is NOT a complete unclosed block + (e.g. a mid-text literal occurrence in a legitimate title) is + preserved unchanged. + """ + if not text: + return "" + if not text.lstrip().startswith("思考"): + return text + match = _THINK_BLOCK_RE.match(text) + if match: + return text[match.end() :] + # Leading 思考 with no closer — the whole response is the think + # block, so consume it and let the caller fall back. + return "" + + categories = [ {"emoji": "💄", "name": "frontend", "description": "UI and style files"}, {"emoji": "👔", "name": "backend", "description": "Business logic"}, @@ -137,7 +175,18 @@ def generate_title_with_llm( if response.message.content and isinstance( response.message.content[0], TextContent ): - title = response.message.content[0].text.strip() + title = _strip_leading_think_block(response.message.content[0].text).strip() + + # A response that contained only a 思考 block yields no + # usable title from the LLM path; fall back to the existing + # user-message truncation rather than handing back the + # peel whitespace. + if not title: + logger.warning( + "LLM title response contained only a 思考 block; " + "falling back to truncation" + ) + return None # Ensure the title isn't too long if len(title) > max_length: diff --git a/tests/sdk/conversation/test_generate_title.py b/tests/sdk/conversation/test_generate_title.py index 858404dd40..4bf06713a6 100644 --- a/tests/sdk/conversation/test_generate_title.py +++ b/tests/sdk/conversation/test_generate_title.py @@ -287,3 +287,99 @@ def test_generate_title_disables_streaming_when_llm_streams(mock_transport): assert mock_transport.call_args.kwargs["enable_streaming"] is False assert mock_transport.call_args.kwargs["on_token"] is None assert streaming_llm.stream is True + + +@patch("openhands.sdk.llm.llm.LLM.completion") +def test_generate_title_strips_leading_think_block(mock_completion): + """A leading 思考…思考 block in the LLM title response is peeled + before the title is returned. Reproduces the Qwen3-behind-Nebius + inline-reasoning leak reported in #4530. + """ + agent = create_test_agent() + conv = Conversation(agent=agent, visualizer=None) + + user_message = create_user_message_event("Help me create a Python script") + conv.state.events.append(user_message) + + custom_llm = LLM(model="gpt-4o-mini", api_key=SecretStr("key"), usage_id="think") + mock_completion.return_value = create_mock_llm_response( + "思考reasoning…思考🐛 Fix the login bug" + ) + + title = conv.generate_title(llm=custom_llm) + + assert title == "🐛 Fix the login bug" + + +@patch("openhands.sdk.llm.llm.LLM.completion") +def test_generate_title_unclosed_think_block_falls_back(mock_completion): + """An unterminated 思考 block is consumed by the same peel; if + nothing real remains the LLM path returns ``None`` so the + user-message truncation fallback supplies the title. + """ + agent = create_test_agent() + conv = Conversation(agent=agent, visualizer=None) + + user_message = create_user_message_event("Help me create a Python script") + conv.state.events.append(user_message) + + custom_llm = LLM( + model="gpt-4o-mini", api_key=SecretStr("key"), usage_id="think-unclosed" + ) + # No closing 思考 — the peel matches nothing, the whole string is + # preserved, but it is the *only* content we got, so the LLM path + # must not hand it back as a title. + mock_completion.return_value = create_mock_llm_response( + "思考half-formed reasoning…" + ) + + title = conv.generate_title(llm=custom_llm) + + # Falls back to the user message, not the raw reasoning block. + assert title == "Help me create a Python script" + + +@patch("openhands.sdk.llm.llm.LLM.completion") +def test_generate_title_think_only_response_falls_back(mock_completion): + """A response that is only a 思考 block (closed or unclosed) yields + no LLM title, so the truncation fallback runs. + """ + agent = create_test_agent() + conv = Conversation(agent=agent, visualizer=None) + + user_message = create_user_message_event("Help me create a Python script") + conv.state.events.append(user_message) + + custom_llm = LLM( + model="gpt-4o-mini", api_key=SecretStr("key"), usage_id="think-only" + ) + mock_completion.return_value = create_mock_llm_response( + "思考deep reasoning chain思考" + ) + + title = conv.generate_title(llm=custom_llm) + + assert title == "Help me create a Python script" + + +@patch("openhands.sdk.llm.llm.LLM.completion") +def test_generate_title_preserves_mid_text_think_literal(mock_completion): + """A mid-text literal 思考 occurrence in a legitimate title is + preserved. The peel only targets a *leading* block, so a title + that legitimately contains the characters ``思考`` (e.g. a + non-English conversation title) is untouched. + """ + agent = create_test_agent() + conv = Conversation(agent=agent, visualizer=None) + + user_message = create_user_message_event("Help me create a Python script") + conv.state.events.append(user_message) + + custom_llm = LLM( + model="gpt-4o-mini", api_key=SecretStr("key"), usage_id="think-mid" + ) + mock_completion.return_value = create_mock_llm_response("🐛 Use 思考 in a sentence") + + title = conv.generate_title(llm=custom_llm) + + assert title == "🐛 Use 思考 in a sentence"