Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 50 additions & 1 deletion openhands-sdk/openhands/sdk/conversation/title_utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Utility functions for generating conversation titles."""

import re
from collections.abc import Callable, Sequence

from openhands.sdk.event import MessageEvent
Expand All @@ -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"},
Expand Down Expand Up @@ -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:
Expand Down
96 changes: 96 additions & 0 deletions tests/sdk/conversation/test_generate_title.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading