Skip to content

fix(models): handle Mistral list content as reasoning and text blocks (py) - #5487

Merged
mkmeral merged 2 commits into
strands-agents:mainfrom
mittalpk:fix/mistral-list-content
Oct 28, 2026
Merged

mkmeral merged 2 commits into
strands-agents:mainfrom
mittalpk:fix/mistral-list-content

Conversation

@mittalpk

Copy link
Copy Markdown
Contributor

Human Overview

Problem: Mistral Large 4 returns responses as ThinkChunk and TextChunk lists, but the SDK expected a string, causing a TypeError.

Fix: The code now handles reasoning and text chunks separately while preserving compatibility with older models in streaming and non-streaming modes.

Testing: Three new unit tests pass, and the integration test’s expected-failure marker was removed; live testing was skipped due to a missing Mistral API key.

Description

Reasoning models such as Mistral Large 4 return content as a list of ThinkChunk/TextChunk objects instead of a string. MistralModel passed that list through as text, which raised a TypeError in the agent loop.

Content is now split into parts: ThinkChunk text becomes reasoningContent blocks, TextChunk becomes text, and chunks without text (such as references) are skipped. Both the streaming and non-streaming paths use it, and plain string content behaves as before.

Related Issues

Resolves #5032

Documentation PR

Not needed.

Type of Change

Bug fix

Testing

Added three unit tests with mocked responses: the reasoning delta, streaming list content split across chunks, and non-streaming list content including a reference chunk. They fail without the fix, and the full Mistral unit suite passes.

Removed the strict xfail on TestMistralLarge4::test_tool_use_turn. The test now reads the first text block, since a reasoning block can come first.

I could not run the integration test without a Mistral API key. hatch run prepare did not resolve its environment locally, so I ran ruff, mypy and pytest directly.

  • I ran hatch run prepare

@mittalpk
mittalpk requested a review from a team as a code owner October 23, 2026 22:06
@mittalpk
mittalpk requested a review from liramon2 October 23, 2026 22:06
@github-actions github-actions Bot added area-model Related to models or model providers bug Something isn't working python Pull requests that update python code labels Oct 23, 2026
@liramon2

Copy link
Copy Markdown
Contributor

@mittalpk Hi, thanks for the contribution. I left a few comments.

@mittalpk
mittalpk force-pushed the fix/mistral-list-content branch from ea7ec39 to 3cbfc47 Compare October 28, 2026 13:43
@mittalpk

Copy link
Copy Markdown
Contributor Author

Thanks, all three done: _content_parts is an instance method, the block switching lives in _content_events, and reasoning blocks now go back to Mistral as thinking chunks (a reasoning-only message is kept). Also rebased on main and dropped the Large 4 xfail from the new smoke table.

@strandly-the-agent strandly-the-agent left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approve — correct fix, no blocking findings.

I reproduced #5032 on the merge-base (TypeError: can only concatenate str (not "list") to str at event_loop/streaming.py:244) and confirmed it's gone on head 3cbfc474c, including the reasoning→tool_use turn that the removed smoke xfail covered. Content blocks stay balanced across every streaming shape I threw at it, and the replayed requests validate against the real mistralai models.

Both halves match Mistral's docs. Streaming: thinking list → transition list → plain string (reasoning docs). Request side: the docs make the replay mandatory, not optional — "always replay the full assistant message (including ThinkChunk) back into the message history" — so _format_request_messages sending {"type": "thinking", ...} is the right call, and it's the same transform Mistral's own Vibe client, @ai-sdk/mistral and the litellm fix (BerriAI/litellm#45546) use.

One non-blocking test-gap suggestion inline. I could not run the live integ test (no MISTRAL_API_KEY in my sandbox), so the TOOL_USE_MARKS xfail removal is unverified against the real API — worth one live run before merge.

✅ What I verified (head 3cbfc474c vs merge-base 15ca21f15)

Gates: pytest tests/strands/models/test_mistral.py tests/strands/event_loop → 264 passed; ruff check + ruff format --check clean; mypy src/strands/models/mistral.py clean. (Other provider test modules don't collect in my sandbox — missing optional deps, unrelated.)

The tests prove behaviour, not the implementation: all 5 new tests fail on the merge-base with the PR's source reverted. The base failure output is the bug itself — {'contentBlockDelta': {'delta': {'text': [ThinkChunk(...), ReferenceChunk(...), TextChunk(...)]}}}.

Adversarial repro (full agent loop, fake Mistral client built from real mistralai 3.6.0 chunk classes, asserting contentBlockStart/Stop balance and validating each outgoing request against ChatCompletionRequest):

Scenario Result
Documented shape: thinking list → transition list ([closing ThinkChunk, TextChunk]) → plain-string answer balanced; records reasoningContent then text
Reasoning + tool_calls, then reasoning + final answer (2 turns) balanced; turn-2 request replays [{"type":"thinking",...}] + tool_calls and validates against the SDK
Empty-string deltas and empty TextChunk interleaved no stray block open/close (matches pre-PR falsy-skip)
text → thinking → text interleaving in one message 3 balanced blocks, order preserved into agent.messages
Stream ends with no finish_reason block left open and content dropped — pre-existing, identical on main (content_started was never reset either); not caused by this PR

Consistency with other providers: reasoning_content as a data_type matches openai.py:587 and llamacpp.py:531, and _content_events mirrors openai.py:825 _stream_switch_content (same "caller closes the open block" contract). No public surface changes — both helpers are private; check-api-review-label is green and no API review is needed.

Evidence (repro script, its output on head, the base reproduction, gate log) is attached to this PR in the portal.

Questions (none blocking)
  1. After this change, a non-reasoning Mistral model (ministral-*, codestral-*) will receive a thinking chunk if the history has one — e.g. update_config(model_id=...) mid-conversation, or a restored session. I looked for evidence that any Mistral model rejects a thinking chunk in an assistant message and found none, so I'm not asking for a change; just confirming you considered that path.
  2. Reading order for a second reviewer: mistral.py:435-484 (the two helpers) → call sites 500-503 and 584-595 → request side 272-277.
Appendix — non-blocking (4)
  1. PR description is stale. It says the strict xfail removed was TestMistralLarge4::test_tool_use_turn and that "the test now reads the first text block, since a reasoning block can come first" — there is no TestMistralLarge4 in the repo and no test was changed that way. What the diff actually removes is the mistral-large-4-0 entry from TOOL_USE_MARKS (tests_integ/models/test_model_smoke.py:37), and that test already uses block.get("toolUse"), so a leading reasoning block is safe. Worth fixing the body before merge. (The existing tests_integ/models/test_model_mistral.py:80,88,100 do index content[0]["text"], but they run mistral-medium-latest with no reasoning_effort, so they still get plain-string content.)
  2. signature / closed are dropped on replay (mistral.py:272-277), and the provider never emits a reasoningContent.signature delta, so they're unrecoverable on a later turn. I went looking for a problem here and didn't find one: signature is OptionalNullable on ThinkChunk, the docs' multi-turn example never mentions it, and the other clients doing this transform also rebuild from text alone. Noting it only so the choice is on the record — streaming.py:265 does have a signature channel if Mistral ever starts requiring it.
  3. TOOL_USE_MARKS: dict[str, pytest.MarkDecorator] = {} plus its lookup at test_model_smoke.py:56 is dead until the next xfail. Fine as a hook; just flagging it.
  4. A cross-provider reasoningContent block carrying only redactedContent (Bedrock shape) is silently skipped by the .get("reasoningText", {}).get("text") guard. Correct behaviour, and Mistral never produces that shape — mentioning it because the skip is implicit rather than counted in skipped.

@codecov

codecov Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.74359% with 4 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
strands-py/src/strands/models/mistral.py 89.74% 0 Missing and 4 partials ⚠️

📢 Thoughts on this report? Let us know!

@github-actions github-actions Bot added complexity/high A touched function exceeds cognitive complexity 25; may be worth splitting size/s labels Oct 28, 2026
@mkmeral
mkmeral merged commit de30e34 into strands-agents:main Oct 28, 2026
36 of 38 checks passed
@mkmeral

mkmeral commented Oct 28, 2026

Copy link
Copy Markdown
Member

Thanks for the bugfix :)

gautamsirdeshmukh pushed a commit to gautamsirdeshmukh/harness-sdk that referenced this pull request Nov 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-model Related to models or model providers bug Something isn't working complexity/high A touched function exceeds cognitive complexity 25; may be worth splitting python Pull requests that update python code size/s

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] MistralModel crashes on reasoning models (Mistral Large 4): content returned as list of ThinkChunk/TextChunk

4 participants