fix(ollama): surface thinking output as a ThinkingBlock - #3146
CryoThrust wants to merge 2 commits into
Conversation
Ollama's /api/chat returns a thinking model's reasoning in message.thinking, but OllamaMessage had no such field and OllamaResponseParser only built TextBlock and ToolUseBlock. The reasoning was silently discarded for both streaming and non-streaming requests, even with the think option enabled. Bind message.thinking on OllamaMessage and emit a ThinkingBlock before the text block, matching the OpenAI and Anthropic modules. Both response paths share the same parser, so a single change covers them; Ollama's request message schema has no thinking field, so the converter is left alone.
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
oss-maintainer
left a comment
There was a problem hiding this comment.
Summary
OllamaMessage.thinking + a ThinkingBlock emitted ahead of the text block, mirroring the OpenAI/Anthropic modules. The change is correctly scoped to the Ollama extension, the block ordering matches the other providers, and stream(...)/blocking both go through parseResponse, so the fix covers both paths. No API breakage: the new DTO field is additive and @JsonInclude(NON_NULL) keeps it out of outbound payloads. CI is green on 9e1cd877 (build ubuntu/windows, Check License, Check Module Sync, codecov/patch). Verdict: good to go apart from the field-name coverage question below.
Findings
- [Warning]
OllamaMessage.java:42— onlythinkingis bound; Ollama usesmessage.reasoningon some models/paths, which would keep the reported symptom alive for those users. - [Info]
OllamaMessage.java:43— response-only field on a DTO shared with the request path; safe today, worth a guard/note so it cannot leak outbound later. - [Info]
OllamaResponseParser.java:54— streaming yields oneThinkingBlockper chunk; the new tests only cover single-response parsing, so the delta-concatenation path is unguarded.
Suggestions
If reasoning fallback is out of scope, mentioning it in the PR description (or in a follow-up issue) is enough — the Closes #3140 claim should not be read as "all thinking-capable Ollama models now surface thinking".
| * {@code think} option. Response-only: Ollama's request message schema does not accept it. | ||
| */ | ||
| @JsonProperty("thinking") | ||
| private String thinking; |
There was a problem hiding this comment.
[Info] OllamaMessage is shared by the request and response paths, so a response-only field is easy to leak back outbound. @JsonInclude(NON_NULL) on the class hides it while it stays null, which is why this is safe today. Could you add a short note (or a guard) in the request formatter so a future change that copies a whole OllamaMessage into an outbound turn cannot start sending thinking back to /api/chat? That would make the "response-only" claim in the javadoc enforced rather than conventional.
There was a problem hiding this comment.
Good catch. In a63902d2, I added an explicit sanitization pass in OllamaChatFormatter.buildRequest and OllamaMultiAgentFormatter.buildRequest that clears thinking (sets it to null) across all messages before the request payload is constructed. So even if an OllamaMessage is copied or reused from a prior response, thinking is never leaked outbound to /api/chat. Also added testBuildRequestClearsThinkingFromOutboundMessages in OllamaChatFormatterTest to verify this behavior.
| * The model's reasoning content, returned by thinking models when the request enables the | ||
| * {@code think} option. Response-only: Ollama's request message schema does not accept it. | ||
| */ | ||
| @JsonProperty("thinking") |
There was a problem hiding this comment.
[Warning] Ollama exposes reasoning text under two field names depending on the surface: message.thinking for /api/chat with think: true, and message.reasoning for some reasoning models (and in the OpenAI-compatible layer). Only thinking is bound here, so for those models the reasoning text is still silently dropped — the same symptom #3140 reports. Consider falling back to reasoning when thinking is absent, or state the supported model/API set explicitly in the PR description so the issue can be closed with a documented scope.
There was a problem hiding this comment.
Added @JsonAlias({"reasoning", "reasoning_content"}) to OllamaMessage.thinking in a63902d2. This handles native /api/chat thinking as well as OpenAI-compatible proxies or models exposing reasoning or reasoning_content, surfacing all of them as a ThinkingBlock. Verified with testReasoningAliasesSurviveJsonDeserialization in OllamaResponseParserTest.
| // 1. Handle Text Content | ||
| // 1. Handle Thinking Content (thinking models with the `think` option enabled) | ||
| if (msg != null && msg.getThinking() != null && !msg.getThinking().isEmpty()) { | ||
| contentBlocks.add(ThinkingBlock.builder().thinking(msg.getThinking()).build()); |
There was a problem hiding this comment.
[Info] In streaming mode every chunk goes through parseResponse, so N thinking deltas become N separate ThinkingBlocks in N ChatResponses. Please confirm the downstream ReActAgent → ThinkingBlock*Event path concatenates them (the OpenAI/Anthropic modules have the same shape, so this is likely already handled) and consider adding one streaming-level test that feeds a multi-chunk thinking sequence — the three new tests are all single-response, so the fragmentation case is currently unguarded.
There was a problem hiding this comment.
Traced the downstream path: in ReActAgent, streaming chunks go through ReasoningContext, which uses ThinkingAccumulator to concatenate incoming ThinkingBlock.getThinking() deltas into the full reasoning content (while emitting each block immediately for real-time events).
To guard against fragmentation regressions, I added testStreamingThinkingAccumulation in OllamaResponseParserTest (a63902d2), which simulates an Ollama multi-chunk delta sequence and verifies ThinkingAccumulator stitches the deltas into the complete string.
…test streaming thinking Address maintainer review feedback: - Bind 'reasoning' and 'reasoning_content' via @JsonAlias on OllamaMessage.thinking so proxies and reasoning models exposing reasoning text are captured. - Enforce that 'thinking' is cleared in buildRequest across chat and multi-agent formatters so response-only thinking content is never sent outbound. - Add multi-chunk streaming accumulation regression test using ThinkingAccumulator, matching the ReActAgent downstream event aggregation path.
请求测 OllamaMessage 不支持 thinking,这个结论是怎么得出的?查了下貌似 Ollama 的文档和源码不一致 |
oss-maintainer
left a comment
There was a problem hiding this comment.
Summary
Delta re-review of a63902d2 (previous round was approved at 9e1cd877). The three items from that round are genuinely addressed — @JsonAlias({"reasoning", "reasoning_content"}) closes the field-name gap, and testStreamingThinkingAccumulation covers the delta-concatenation path. CI is green on this head (build ubuntu/windows, Check License, Check Module Sync, codecov/patch) and license/cla is success.
What I want to slow down is the new outbound sanitization added in this commit. Its stated premise — "thinking is response-only; Ollama's request schema does not accept it" — does not hold against upstream, and that is the same question @jujn raised on the PR at 04:09 which is still unanswered:
api/types.go(ollama/ollama@main) declaresThinking string \json:"thinking,omitempty"`on theMessagestruct used forChatRequest.Messages— inbound history accepts it,omitempty` makes it optional rather than illegal.docs/api.md→ Generate a chat completion → Parameters → "Themessageobject has the following fields" liststhinking.docs/capabilities/tool-calling.mdcomposes the follow-up request asmessages.append({'role': 'assistant', 'thinking': thinking, 'content': content, 'tool_calls': tool_calls})— thinking is deliberately echoed back forthink: true+ tools.
On top of that, the guard cannot bite today: request messages come from OllamaMessageConverter, which maps only TextBlock/ImageBlock/ToolUseBlock and never ThinkingBlock, so an outbound OllamaMessage.thinking is always null — which is exactly why @JsonInclude(NON_NULL) was already sufficient. Net effect: two new copies of a loop that changes nothing now, and encodes a rule that will discard reasoning content the moment thinking is threaded through history.
Findings
- [Warning]
OllamaMessage.java:43— "request schema does not accept it" is contradicted byapi/types.goanddocs/api.md; reword, since the guard's rationale depends on it. - [Warning]
OllamaChatFormatter.java:455— no-op today, wrong direction against the documented tool-call continuation flow; also mutates the caller's message list in place. - [Info]
OllamaMultiAgentFormatter.java:303— second copy of the same loop; one helper if it stays. - [Info]
OllamaChatFormatterTest.java:543— asserts the in-place mutation, not the serialized payload.
Suggestions
Either drop the guard and reword the DTO note, or keep it scoped (skip assistant messages carrying tool_calls) with a comment citing the upstream doc. Then pin the behaviour in the test that actually matters — serialize the built request and assert no "thinking" key appears — so the intent survives future edits. Nothing here touches the parser change itself, which looks right: ThinkingBlock before TextBlock, shared by streaming and blocking paths, no core impact.
Holding this as a comment rather than a re-approval until @jujn's question is resolved — the reasoning-block emission is fine, but the commit's stated invariant is not, and it is the part a future reader will trust.
Automated review by github-manager-bot
| * The model's reasoning content, returned by thinking models when the request enables the | ||
| * {@code think} option. In native Ollama {@code /api/chat} this is returned in {@code thinking}; | ||
| * some reasoning models or proxies expose it under {@code reasoning} or {@code reasoning_content}. | ||
| * Response-only: Ollama's request message schema does not accept it. |
There was a problem hiding this comment.
[Warning] Response-only: Ollama's request message schema does not accept it is not accurate, and it is the premise behind the new outbound strip in a63902d2 — it is also exactly the question @jujn left on this PR, which is still open.
Upstream accepts thinking inbound:
api/types.go(ollama/ollama@main):Thinking stringjson:"thinking,omitempty"`` sits on the sameMessagestruct used for `ChatRequest.Messages`, i.e. request history — `omitempty` means optional, not rejected.docs/api.md→ Generate a chat completion → Parameters → "Themessageobject has the following fields":thinking: (for thinking models) the model's thinking process.docs/capabilities/tool-calling.mdbuilds the follow-up request asmessages.append({'role': 'assistant', 'thinking': thinking, 'content': content, 'tool_calls': tool_calls})— the accumulated reasoning is deliberately echoed back.
Suggested wording: the Java request path does not populate it today, rather than the wire schema rejecting it.
| if (messages != null) { | ||
| for (OllamaMessage msg : messages) { | ||
| if (msg != null && msg.getThinking() != null) { | ||
| msg.setThinking(null); |
There was a problem hiding this comment.
[Warning] This is a guard against a case that cannot happen today, and it blocks the case that upstream recommends.
Today it is a no-op on the real path: request messages are built by OllamaMessageConverter, which maps only TextBlock / ImageBlock / ToolUseBlock — a ThinkingBlock from a prior turn is already dropped during conversion, so an outbound OllamaMessage never carries thinking (which is also why @JsonInclude(NON_NULL) alone was enough). So the guard adds a rule, not a fix.
The rule it encodes is the wrong direction: per docs/capabilities/tool-calling.md, for think: true + tools the assistant thinking should travel back with tool_calls in the follow-up request. If someone later wires ThinkingBlock into the history (the natural next step for thinking models), this loop will silently discard it and the model loses its own reasoning across the tool round-trip.
Options, any is fine:
- Remove the loop and instead make the intent explicit where it belongs — keep
ThinkingBlockout ofOllamaMessageConverter, or map it and let it go outbound — plus reword theOllamaMessagenote (see the comment on that line); - Keep it but scope it (skip assistant messages that carry
tool_calls) and reference the upstream doc so the next reader does not widen it.
Non-blocking either way: setThinking(null) mutates the caller's OllamaMessage objects in place, so a list reused across a retry or another provider is silently rewritten. Building sanitized copies keeps buildRequest pure.
| if (messages != null) { | ||
| for (OllamaMessage msg : messages) { | ||
| if (msg != null && msg.getThinking() != null) { | ||
| msg.setThinking(null); |
There was a problem hiding this comment.
[Info] Same loop, second copy. If the guard stays at all, one shared helper (or a single call in OllamaChatModel before buildRequest) is enough — the two formatters otherwise drift, and a future fix (e.g. scoping it for tool-call continuations) has to be applied twice.
|
|
||
| assertNotNull(request); | ||
| assertEquals(2, request.getMessages().size()); | ||
| assertNull(request.getMessages().get(1).getThinking()); |
There was a problem hiding this comment.
[Info] request.getMessages() is the very list that was handed to buildRequest, so assertNull(...) pins the in-place mutation instead of the wire format — it would still pass if the serializer emitted thinking. For the intent stated in the comment, assert on the payload: serialise the built request (the same codec the transport uses) and assert the JSON has no "thinking" key. If the tool-call continuation case is meant to keep thinking, that positive case needs an assertion too — right now no test distinguishes "never sent" from "dropped by mistake".
Summary
message.thinkingonOllamaMessage(response-only; Ollama's request schema has no such field)ThinkingBlockfromOllamaResponseParserbefore the text block, matching the OpenAI and Anthropic modulesBoth
stream(...)and the blocking call go through the sameparseResponse, so this covers streaming and non-streaming, andReActAgentalready turns aThinkingBlockinto theThinkingBlock*Events. No core changes.Tests
Added to
OllamaResponseParserTest:message.thinkingdeserializes and producesThinkingBlockthenTextBlock(this is the end-to-end shape of the reported case)ThinkingBlockWithout the DTO/parser change these do not even compile against the new getter and the JSON case fails, so they are a real regression guard.
agentscope-extensions-model-ollama: 169 run, 0 failures, 1 skippedspotless:checkcleanCloses #3140.