Skip to content

feat: enforce canonical reasoning messages - #148

Open
hallerite wants to merge 3 commits into
mainfrom
codex/strict-reasoning-content
Open

feat: enforce canonical reasoning messages#148
hallerite wants to merge 3 commits into
mainfrom
codex/strict-reasoning-content

Conversation

@hallerite

@hallerite hallerite commented Sep 1, 2026

Copy link
Copy Markdown
Member

Summary

  • define one canonical assistant-message schema: reasoning_content, content, and tool_calls
  • validate that schema at every renderer boundary; reject legacy reasoning, thinking content parts, and inline <think>...</think> markup with normalization guidance
  • expose supports_reasoning_content on every renderer and make DeepSeek V3, Qwen3-VL, Llama 3, and the opaque default renderer reject non-empty structured reasoning instead of silently dropping it
  • project canonical reasoning into model-native wire formats inside reasoning-capable renderers, including Kimi K2's content-only Jinja template
  • keep explicit raw native-wire modes raw: inline tags remain allowed there, while structured reasoning is rejected when that mode cannot represent it
  • preserve output parsing as the inverse transformation from model-native delimiters to canonical ParsedResponse fields

Rationale

Legacy datasets should be normalized before they reach a renderer. Renderers should not guess whether <think> text inside assistant content is reasoning or literal visible content, and non-reasoning templates should never discard a populated reasoning_content field without telling the caller.

This intentionally declines backward compatibility with inline reasoning input, including the approach proposed in #147. Dataset adapters own legacy-to-canonical conversion; typed renderers own canonical-to-native serialization.

Tests

  • full offline suite: 9,695 passed, 76 skipped
  • review-focused canonical-validation and Hy3 parity suite: 1,399 passed
  • all 7,206 shared byte-parity cases pass
  • Ruff and git diff --check pass

Note

Enforce canonical reasoning messages across all renderers

  • Adds validate_canonical_messages() in base.py that rejects legacy reasoning fields, thinking content parts, unsupported reasoning_content, and inline <think> markup in assistant message history.
  • Adds get_structured_reasoning() helper that reads only reasoning_content; all renderers replace ad-hoc inline <think> parsing and legacy field fallbacks with this single call.
  • Adds supports_reasoning_content: bool to the Renderer protocol; non-reasoning renderers (DeepSeek V3, Qwen3-VL, Llama 3, DefaultRenderer) now raise ValueError when given reasoning_content.
  • Removes the ThinkingPart type and reasoning field from the Message schema; ContentPart now only accepts TextPart | ImagePart | VideoPart.
  • Introduces tests/test_structured_reasoning.py with a parametrized suite covering all enforcement rules across renderer families.
  • Risk: any caller passing legacy reasoning fields, thinking content parts, or inline <think> markup in historical assistant turns will now receive a ValueError at render time.

Macroscope summarized 6afbd78.


Note

Medium Risk
This is a breaking input contract for legacy datasets and any caller relying on inline think tags or silent dropping of reasoning_content; behavior is intentional but will fail fast at render time across many model families.

Overview
This PR tightens the assistant-message contract so render input must use reasoning_content, content, and tool_calls only—no guessing from wire-format text in content.

Validation and API: Adds validate_canonical_messages (called from each renderer’s render path) and get_structured_reasoning, which reads only reasoning_content. The Renderer protocol gains supports_reasoning_content. Legacy reasoning, thinking content parts, and inline <think> markup in content raise with normalization guidance; ThinkingPart and the reasoning field are removed from the public message types.

Behavior: Reasoning-capable renderers project reasoning_content into native wire formats (including Kimi K2, which previously ignored structured reasoning). DeepSeek V3, Qwen3-VL, Llama 3, and DefaultRenderer set supports_reasoning_content = False and error on non-empty reasoning_content instead of dropping it. Explicit raw passthrough modes (e.g. Laguna render_assistant_messages_raw, Hy3 raw_last_assistant) still allow inline markup on raw turns but reject structured reasoning when the mode cannot represent it.

Tests/docs: Parity drops the inline-thinking-history scenario, adds Kimi K2 reference projection, and introduces test_structured_reasoning.py plus updates across bridge, roundtrip, and model-specific tests.

Reviewed by Cursor Bugbot for commit 6afbd78. Bugbot is set up for automated code reviews on this repo. Configure here.

@macroscopeapp

macroscopeapp Bot commented Sep 1, 2026

Copy link
Copy Markdown

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This PR changes the shared assistant-message contract across many production renderers, rejecting legacy forms and altering Kimi’s serialized reasoning representation. The broad compatibility and runtime blast radius warrants human review.

Notes:

  • Macroscope's correctness review did not run, so approvability was decided on eligibility alone.

You can add or adjust custom eligibility rules. Learn more.

@hallerite hallerite changed the title fix: require structured reasoning input feat: enforce canonical reasoning messages Sep 1, 2026

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit be20242. Configure here.

Comment thread renderers/base.py
Comment thread renderers/hy3.py
Comment thread renderers/base.py
if message.get("role") != "assistant":
continue

if "reasoning" in message:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we should give escape hatches to bypass these checks? Forcing users to re-create their datasets can be pretty onerous.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think we should support reasoning and reasoning_content, myb even thinking. i have seen all of these in the wild

Comment thread renderers/base.py
"<think>" in fragment or "</think>" in fragment
for fragment in text_fragments
):
raise ValueError(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar edge-case concerns here: I've definitely seen datasets with some stray <think> tokens in assistant content messages. It's good to know when it happens, but also maybe annoying to hard error on them.

It's also logically possible to me that the assistant might have <think> in its legitimate reasoning content, e.g. if the user is asking about LLM chat formatting. Is that wrong?

Comment thread renderers/base.py
delimiters into ``reasoning_content``.
"""
value = message.get("reasoning_content")
return value if isinstance(value, str) else ""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe warn about dropping non-string content?

@garrett361 garrett361 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left a few comments asking about hard errors and edge cases, but otherwise looks good! Please address before deciding to merge or not.

Comment thread renderers/base.py
if message.get("role") != "assistant":
continue

if "reasoning" in message:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think we should support reasoning and reasoning_content, myb even thinking. i have seen all of these in the wild

Comment thread renderers/base.py
)

reasoning = message.get("reasoning_content")
if reasoning is not None and not isinstance(reasoning, str):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also None types should be allowed and just ignored imo

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants