Skip to content

fix(ollama): surface thinking output as a ThinkingBlock - #3146

Merged
jujn merged 5 commits into
agentscope-ai:mainfrom
CryoThrust:fix/ollama-thinking-block
Sep 22, 2026
Merged

jujn merged 5 commits into
agentscope-ai:mainfrom
CryoThrust:fix/ollama-thinking-block

Conversation

@CryoThrust

Copy link
Copy Markdown
Contributor

Summary

  • bind message.thinking on OllamaMessage (response-only; Ollama's request schema has no such field)
  • emit a ThinkingBlock from OllamaResponseParser before the text block, matching the OpenAI and Anthropic modules

Both stream(...) and the blocking call go through the same parseResponse, so this covers streaming and non-streaming, and ReActAgent already turns a ThinkingBlock into the ThinkingBlock*Events. No core changes.

Tests

Added to OllamaResponseParserTest:

  • a raw Ollama JSON payload with message.thinking deserializes and produces ThinkingBlock then TextBlock (this is the end-to-end shape of the reported case)
  • a thinking-only chunk produces just the ThinkingBlock
  • a response without thinking is unchanged

Without 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 skipped
  • spotless:check clean

Closes #3140.

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

codecov Bot commented Sep 14, 2026 •

Copy link
Copy Markdown

@oss-maintainer oss-maintainer 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.

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 — only thinking is bound; Ollama uses message.reasoning on 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 one ThinkingBlock per 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;

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.

[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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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")

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.

[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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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());

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.

[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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.
oss-maintainer

This comment was marked as abuse.

@jujn

jujn commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator

bind message.thinking on OllamaMessage (response-only; Ollama's request schema has no such field)

请求测 OllamaMessage 不支持 thinking,这个结论是怎么得出的?查了下貌似 Ollama 的文档和源码不一致

@oss-maintainer oss-maintainer 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.

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) declares Thinking 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 → "The message object has the following fields" lists thinking.
  • docs/capabilities/tool-calling.md composes the follow-up request as messages.append({'role': 'assistant', 'thinking': thinking, 'content': content, 'tool_calls': tool_calls}) — thinking is deliberately echoed back for think: 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 by api/types.go and docs/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.

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.

[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 string json:"thinking,omitempty"`` sits on the same Message struct used for `ChatRequest.Messages`, i.e. request history — `omitempty` means optional, not rejected.
  • docs/api.md → Generate a chat completion → Parameters → "The message object has the following fields": thinking: (for thinking models) the model's thinking process.
  • docs/capabilities/tool-calling.md builds the follow-up request as messages.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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed completely. Updated the javadoc on thinking in 144049a8: removed the inaccurate "response-only" note and explained that Ollama's schema accepts it optionally (e.g. for tool calling reasoning echo), while OllamaResponseParser extracts it inbound into a ThinkingBlock.

if (messages != null) {
for (OllamaMessage msg : messages) {
if (msg != null && msg.getThinking() != null) {
msg.setThinking(null);

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.

[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:

  1. Remove the loop and instead make the intent explicit where it belongs — keep ThinkingBlock out of OllamaMessageConverter, or map it and let it go outbound — plus reword the OllamaMessage note (see the comment on that line);
  2. 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Went with Option 1 in 144049a8: removed the stripping loop entirely from OllamaChatFormatter. That keeps buildRequest pure without in-place mutation, and leaves the door open for tool-calling continuations to echo reasoning back as upstream recommends.

if (messages != null) {
for (OllamaMessage msg : messages) {
if (msg != null && msg.getThinking() != null) {
msg.setThinking(null);

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.

[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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed the loop here as well in 144049a8.


assertNotNull(request);
assertEquals(2, request.getMessages().size());
assertNull(request.getMessages().get(1).getThinking());

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.

[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".

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated the tests in 144049a8 to assert directly on the serialized payload:

  • testRequestSerializationOmitsThinkingWhenNull: when thinking is null (the usual converted case), @JsonInclude(NON_NULL) omits the "thinking" key entirely;
  • testRequestSerializationIncludesThinkingWhenPresent: when thinking is present, it serializes "thinking" into the JSON payload properly.

- Update OllamaMessage.thinking javadoc: Ollama's ChatRequest.Messages supports
  thinking (e.g. for tool calling reasoning echo), so remove the inaccurate
  'response-only' statement.
- Remove the in-place msg.setThinking(null) stripping loops from buildRequest
  in OllamaChatFormatter and OllamaMultiAgentFormatter.
- Update OllamaChatFormatterTest to verify payload serialization: thinking is
  omitted when null via @JsonInclude(NON_NULL), and preserved when set.
@CryoThrust

Copy link
Copy Markdown
Contributor Author

@jujn 确实是我之前看走眼了!我一开始只看了几个 response 的 json 例子和最初提 issue 时的描述,误以为入参只认 content 和 tool_calls,把 thinking 当成纯返回字段了。

后来回头翻了下 Ollama 官方的 api/types.go 和 docs/capabilities/tool-calling.md,发现请求和响应共用的同一个 Message 结构体,而且多轮工具调用时官方就是把 assistant 的 thinking 带在上下文里往回传的。

上一版我在 formatter 里多加的那层强行置空拦截纯属帮倒忙,最新的提交(144049a8)已经把那段置空代码全部删掉了,注释也修正了。多谢指出!

oss-maintainer

This comment was marked as abuse.

@oss-maintainer

This comment was marked as abuse.

@jujn
jujn merged commit ed77530 into agentscope-ai:main Sep 22, 2026
6 checks passed
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.

[Bug]: OllamaChatModel drops thinking output — OllamaMessage DTO has no "thinking" field, so reasoning is silently discarded

3 participants