Skip to content
Merged
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
24 changes: 23 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 Down Expand Up @@ -59,6 +60,21 @@ def extract_first_user_message(events: Sequence[Event]) -> str | None:
return None


_REASONING_BLOCK = re.compile(r"<think>.*?</think>", re.DOTALL | re.IGNORECASE)
_UNCLOSED_REASONING = re.compile(r"<think>.*", re.DOTALL | re.IGNORECASE)


def strip_reasoning_blocks(text: str) -> str:
"""Remove inline ``<think>`` reasoning from a model's text.

Providers that do not split chain-of-thought into ``reasoning_content`` return it
inline in ``content``. An unterminated block means the response was cut mid-thought,
so everything from the opening tag on is reasoning too.
"""
text = _REASONING_BLOCK.sub("", text)
return _UNCLOSED_REASONING.sub("", text)


def generate_title_with_llm(
message: str,
llm: LLM,
Expand Down Expand Up @@ -132,7 +148,13 @@ 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_reasoning_blocks(response.message.content[0].text).strip()

if not title:
logger.warning(
"LLM returned only reasoning content for title generation"
)
return None

# Ensure the title isn't too long
if len(title) > max_length:
Expand Down
42 changes: 42 additions & 0 deletions tests/sdk/conversation/test_generate_title.py
Original file line number Diff line number Diff line change
Expand Up @@ -428,3 +428,45 @@ def test_title_uses_real_http_transport(title_http_server, tmp_path, mode):
assert bool(body.get("stream")) == (mode == "subscription")
if mode != "chat":
assert body["store"] is False


@patch("openhands.sdk.llm.llm.LLM.completion")
def test_generate_title_strips_inline_reasoning(mock_completion):
"""Guards #4530.

Providers that do not split chain-of-thought into `reasoning_content` return it
inline as `<think>...</think>`. The title is consumed verbatim, so without
stripping, truncation to `max_length` keeps the reasoning and discards the title.
"""
llm = LLM(model="qwen3-32b", api_key=SecretStr("test-key"), usage_id="t")
mock_completion.return_value = create_mock_llm_response(
"<think>The user wants a CSV summary script. I will pick the features "
"emoji and keep it short.</think>✨ Summarise a CSV in Python"
)

title = generate_title_with_llm("Help me summarise a CSV", llm)

assert title == "✨ Summarise a CSV in Python"


@patch("openhands.sdk.llm.llm.LLM.completion")
def test_generate_title_strips_unterminated_reasoning(mock_completion):
"""An unterminated block means the response was cut mid-thought, so there is no
title to salvage and the caller falls back to a truncated message title."""
llm = LLM(model="qwen3-32b", api_key=SecretStr("test-key"), usage_id="t")
mock_completion.return_value = create_mock_llm_response(
"<think>Let me consider what this conversation is really about"
)

assert generate_title_with_llm("Help me summarise a CSV", llm) is None


@patch("openhands.sdk.llm.llm.LLM.completion")
def test_generate_title_keeps_text_without_reasoning(mock_completion):
"""A normal response is unaffected."""
llm = LLM(model="gpt-4o-mini", api_key=SecretStr("test-key"), usage_id="t")
mock_completion.return_value = create_mock_llm_response("✨ Create Python Script")

assert generate_title_with_llm("Help me write a script", llm) == (
"✨ Create Python Script"
)
Loading