Skip to content
Open
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
17 changes: 13 additions & 4 deletions aider/reasoning_tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,23 @@ def remove_reasoning_content(res, reasoning_tag):
if not reasoning_tag:
return res

open_tag = f"<{reasoning_tag}>"
closing_tag = f"</{reasoning_tag}>"
# Whether the response contained an opening tag. Captured before the
# balanced-block substitution below removes it.
had_open_tag = open_tag in res

# Try to match the complete tag pattern first
pattern = f"<{reasoning_tag}>.*?</{reasoning_tag}>"
res = re.sub(pattern, "", res, flags=re.DOTALL).strip()

# If closing tag exists but opening tag might be missing, remove everything before closing
# tag
closing_tag = f"</{reasoning_tag}>"
if closing_tag in res:
# Only treat a remaining closing tag as an orphan reasoning close when no
# opening tag was present at all (e.g. a continuation whose opening tag
# was emitted in a prior chunk); in that case the text before it is
# reasoning and is dropped. If there was an opening tag, any closing tag
# still left after the substitution above is a *literal* occurrence in
# the model's answer (e.g. inside code or prose) and must be preserved.
if not had_open_tag and closing_tag in res:
# Split on the closing tag and keep everything after it
parts = res.split(closing_tag, 1)
res = parts[1].strip() if len(parts) > 1 else res
Expand Down
21 changes: 21 additions & 0 deletions tests/basic/test_reasoning.py
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,27 @@ def test_remove_reasoning_content(self):
text = "Just regular text"
self.assertEqual(remove_reasoning_content(text, "think"), text)

def test_remove_reasoning_content_literal_closing_tag(self):
"""A literal </think> in the model's answer (e.g. inside code or
prose) must not be discarded when the response also contained a real
(balanced) reasoning block.

Regression: the orphan-close heuristic split on the first remaining
</think> after stripping balanced blocks, silently dropping the real
answer that preceded a literal </think>.
"""
text = "<think>reasoning here</think>Here is code: print('</think>')"
expected = "Here is code: print('</think>')"
self.assertEqual(remove_reasoning_content(text, "think"), expected)

def test_remove_reasoning_content_orphan_closing_tag_stripped(self):
"""When no opening tag is present at all (e.g. a continuation where
the opening tag was emitted in a prior chunk), an orphan </think> is
still stripped along with the reasoning text that preceded it."""
text = "leftover reasoning here</think>actual answer"
expected = "actual answer"
self.assertEqual(remove_reasoning_content(text, "think"), expected)

def test_send_with_reasoning(self):
"""Test that reasoning content from the 'reasoning' attribute is properly formatted
and output."""
Expand Down