fix(sdk): strip inline reasoning from LLM-generated titles - #4703
Conversation
|
👋 This PR needs a couple of things fixed before OpenHands can review it:
Push an update once this is addressed and this check re-runs automatically. This is an automated check - no AI was used to generate this comment. |
|
🚦 CI is currently failing on this PR's latest commit. Please fix the failing checks before OpenHands reviews it - this is re-checked automatically once you push a new commit. (A maintainer can also request This is an automated check - no AI was used to generate this comment. |
ee7eef5 to
de50f13
Compare
enyst
left a comment
There was a problem hiding this comment.
Thank you, @aniketwaghh ! It seems precommit is failing in CI, could you please take a look?
Providers that do not split chain-of-thought into reasoning_content return it inline as <think>...</think>. generate_title_with_llm consumed the text verbatim, so truncation to max_length kept the reasoning and discarded the title entirely. Strip closed and unterminated reasoning blocks before use, and treat a reasoning-only response as empty so the caller falls back to a truncated message title. Fixes OpenHands#4530
de50f13 to
96564ec
Compare
|
Fixed in 96564ec — the rebase onto main dropped the two blank lines between |
enyst
left a comment
There was a problem hiding this comment.
Hi, I'm OpenHands-DeepSeek-v4-Pro, reviewing this PR autonomously on behalf of the maintainer. 👋
Taste Rating
🟡 Acceptable — clean, well-tested, and correctly evidenced; one divergence from the issue's stated acceptance criteria worth reconciling.
This is a genuinely good, small fix. The diagnosis is precise, the evidence section is unusually strong (real-model reproduction against mlx-community/Qwen3-4B-4bit, plus a before/after comparison on HEAD~1), and CI is fully green. The regex markers (<think> / </think>) match the exact \u003cthink\u003e format named in #4530, so the fix targets the right provider shape.
A few observations:
[IMPROVEMENT OPPORTUNITIES]
-
[
title_utils.py,strip_reasoning_blocks] Special case vs. stated requirement: #4530 acceptance criterion #3 explicitly asks for a conservative first-block-only peel so that a genuine mid-text literal<think>in a title is preserved. This implementation calls_REASONING_BLOCK.sub("", text)then_UNCLOSED_REASONING.sub("", text)— i.e. it strips every<think>…</think>block, and on an unterminated<think>discards everything from the first marker to end of string, regardless of position. A title that legitimately contained the marker (however contrived) would be truncated. That may be more robust for the actual goal of "never leak reasoning," but it diverges from what the issue explicitly specified and is not guarded by a test. Either (a) restrict the peel to the leading block, or (b) add a "mid-text literal preserved" test and note that strip-all is intentional. As written,test_generate_title_keeps_text_without_reasoningonly covers a response with no reasoning at all — not a mid-text marker. -
[title_utils.py,
strip_reasoning_blocks] Simplification (optional): the two-regex approach is clear and readable — no change required. Keeping two named constants is fine. Not worth bikeshedding.
Comments: docstrings are appropriately scoped and explain why an unterminated block means "no title to salvage." No noise.
Tests: The three new tests exercise the real generate → (mocked) completion → response-processing path and assert on the returned title/None, consistent with the file's existing conventions. They are not "assert the mock was called" tests — they fail if the strip logic regresses (the author verified by reverting the source change). Good.
Breaking-change check: The new return None on empty-after-strip is behavior-equivalent for callers — generate_title_from_message already treated "" as falsy and fell back to generate_fallback_title. None adds clarity and a diagnostic warning. No breakage.
VERDICT: ✅ Worth merging — sound, well-evidenced fix. The only real ask is to resolve the strip-all-vs-first-block divergence from the issue's acceptance criteria (add the mid-text test or document the intent).
[RISK ASSESSMENT]
- [Overall PR]
⚠️ Risk Assessment: 🟢 LOW — isolated to a single helper, purely additive text post-processing, no public API shape change, no dependency changes, no security surface, full CI green. The divergence above is a correctness nuance on a rare edge, not a blocking risk.
KEY INSIGHT: The fix is correct on the happy path but silently changes semantics from "peel the leading reasoning block" to "strip reasoning markers everywhere," a slightly broader (and untested-for) guarantee than the issue requested — make that intent explicit.
Improve this review? If any feedback above seems incorrect or irrelevant to this repository, teach me to do better: add a
.agents/skills/custom-codereview-guide.mdfile to your branch (withtriggers: [/codereview]in its frontmatter) documenting the context I'm missing, then re-request review — I read guidelines from the PR branch. See the customization docs.
…#4703) Co-authored-by: Engel Nyst <engel.nyst@gmail.com>
Co-authored-by: openhands <openhands@all-hands.dev>
HUMAN:
Reproduced #4530 locally against a Qwen3 model served with mlx-lm: the autogenerated title came back as a truncated
<think>block rather than the title the model actually produced. Fix and evidence below.AGENT:
Why
generate_title_with_llm()takesresponse.message.content[0].textverbatim. A reasoning model whose provider does not split chain-of-thought intoreasoning_contentreturns it inline as<think>…</think>, so the reasoning becomes part of the title.The truncation makes it worse than a cosmetic prefix. With the default
max_length=50, the title is cut to the first 47 characters, which are all reasoning, so the actual title is discarded rather than merely preceded by noise.Summary
<think>…</think>blocks from the model's text before trimming and truncating.<think>as reasoning through to the end, since the response was cut mid-thought.Nonewhen nothing survives, so the caller falls back to its truncated-message title instead of storing an empty one.Issue Number
Fixes #4530
Evidence
Reproduced against a real reasoning model rather than a fixture:
mlx-community/Qwen3-4B-4biton Metal viamlx-lm. It produced 1971 characters, verbatim:Worth stating because it cuts against the result: MLX's own server does not exhibit this bug. It parses the thinking block into a separate field and returns clean
content, so it is a well-behaved provider. What reproduces the bug is delivering that same model output incontent, which is what the providers named in the issue do. The reasoning text above is real; the provider shape is the issue's.Against
HEAD~1of this branch, which is unmodifiedmain:Same input on this branch:
The model produced a perfectly good title. Before this change it was discarded, because truncation to
max_lengthconsumed the reasoning first and never reached it.A second real-model observation that motivated handling unterminated blocks: asked for a title with a 220-token budget, the same model emitted
<think>and hit the cap before closing it. A response cut mid-thought has no title to salvage, so stripping only balanced blocks would leave the opening tag and the reasoning behind it in the title.How to Test
To see the behaviour rather than the assertions, check out
HEAD~1of this branch (unmodifiedmain), feed a response whose text is<think>…</think>✨ Some Titlethroughgenerate_title_with_llm, and observe the stored title truncate to 50 characters of reasoning. On this branch the same input yields✨ Some Title.Three tests added to
tests/sdk/conversation/test_generate_title.py:None, so the caller falls backReverting only the source change turns the first two red and leaves the third green, which is the intended split: the third is a no-regression check, not a bug guard.
Note on scope
#4564also touchestitle_utils.pybut is about custom title prompts; I checked its diff for reasoning handling and there is none, so these are orthogonal beyond a possible textual conflict.