fix(core): keep the model's reasoning in the assistant turn by L4XB · Pull Request #288 · FellouAI/eko · GitHub
Skip to content

fix(core): keep the model's reasoning in the assistant turn - #288

Open
L4XB wants to merge 1 commit into
FellouAI:mainfrom
L4XB:fix/keep-reasoning-in-assistant-turn
Open

L4XB wants to merge 1 commit into
FellouAI:mainfrom
L4XB:fix/keep-reasoning-in-assistant-turn

Conversation

@L4XB

@L4XB L4XB commented Sep 13, 2026 •

Copy link
Copy Markdown

Fixes #281.

The bug

DeepSeek's thinking mode is on by default, and once a turn has called a tool the API requires that turn's reasoning_content back on every subsequent request. Without it the next call is a 400, which is what the issue reports.

eko never sends it — not because of a provider gap, but because the reasoning is discarded before the provider ever sees it:

  • callLLM handles reasoning-start / reasoning-delta / reasoning-end only to emit thinking stream events for the UI. The array it returns is built from streamText and toolParts alone, so the reasoning is gone the moment the stream ends.
  • convertAssistantContent, which turns that array into { role: "assistant", content }, then filters explicitly to text and tool-call.

The provider layer was ready the whole time. @ai-sdk/openai-compatible walks an assistant message's parts and does exactly what DeepSeek needs:

case "reasoning": { reasoning += part.text; break; }
...
...(reasoning.length > 0 ? { reasoning_content: reasoning } : {}),

So the part only had to survive eko's own assembly of the turn.

The fix

reasoning-end records the block, carrying providerMetadata through as providerOptions — Anthropic's signature rides in there, and a thinking block replayed without its signature is rejected too, so the same fix covers extended thinking. thinkText resets afterwards so a turn with several reasoning blocks keeps them apart instead of concatenating them into the second one.

Both return paths now go through a single collected(), which puts reasoning ahead of text and tool calls. That also means the abort path returns the same shape rather than a second hand-built array that could drift.

LLMAssistantPart names the widened union once instead of repeating it at a dozen signatures.

What the widened union means for existing consumers

Adding a third part type to an array others already read is the risky half, so each consumer was checked rather than assumed:

site before now
agent/base.ts results.every(s => s.type == "text") decided "no tool was called", then mapped s.text folds reasoning out first, so a thinking model's plain answer is still recognised as one
chat/chat-agent.ts every non-text part was treated as a tool call — a reasoning part would have become undefined tool does not exist skipped explicitly
chat/chat-llm.ts convertAssistantToolResults mapped parts into EkoMessageAssistantPart filters reasoning out: that persisted format has no reasoning variant, and extending it is a separate change
memory/index.ts passes unknown parts through untouched unchanged behaviour, type widened only

Verification

npx jest test/llm/reasoning-round-trip.test.ts — 6 passed. The suite is offline: it drives callLLM with a hand-built ReadableStream of provider chunks, so it needs no API key.

Each test was checked against a mutation, so none of them passes for an unrelated reason:

mutation tests that fail
never push the block at reasoning-end 5 of 6
collected() omits reasoningParts 5 of 6 (including the abort path)
collected() emits reasoning last 2
never reset thinkText between blocks 1 (["first", "firstsecond"])
convertAssistantContent filters reasoning out again 1

npx tsc --noEmit reports only the pre-existing agent-llm.ts:47 providerOptions error, which is present on a clean checkout of main as well (verified by stashing). npx rollup -c builds.

Not covered

The alternative the issue also mentions — disabling thinking mode for DeepSeek — is not done here. Sending the reasoning back is the behaviour-preserving half: it keeps the model's thinking available to both the user and the model, and it fixes Anthropic's signature case at the same time. A switch to turn thinking off is a configuration feature, not a bug fix, and belongs in its own change.

Not verified against the live DeepSeek API — I have no key for it. What is verified is that the reasoning part now reaches the provider layer, and that @ai-sdk/openai-compatible turns that part into reasoning_content.

A thinking-mode provider treats its reasoning as part of the turn, not as
decoration. DeepSeek answers 400 to every follow-up request once a turn called
a tool and the assistant message came back without `reasoning_content`, and
Anthropic rejects a thinking block whose signature was not returned.

eko streamed reasoning to the UI as `thinking` events and then threw it away:
`callLLM` never put a reasoning part in the array it returns, and
`convertAssistantContent` filtered the prompt down to text and tool calls. The
provider layer is ready for it — `@ai-sdk/openai-compatible` maps an assistant
`reasoning` part onto `reasoning_content` — so the part only had to survive
eko's own assembly of the turn.

`reasoning-end` now records the block with the `providerMetadata` that carries
the signature, `thinkText` resets so several blocks stay separate, and one
`collected()` builds the turn for both the normal and the abort path with
reasoning ahead of text and tool calls.

`LLMAssistantPart` names the widened union once. The consumers that assumed
text-or-tool-call are adjusted rather than left to misread a reasoning part:
the agent's "text-only turn" check folds reasoning out before deciding no tool
was called, the chat agent skips it instead of executing it as a call, and the
persisted `EkoMessage` transcript drops it, since that format has no reasoning
variant and extending it is a separate change.
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.

使用deepseek的模型由于其默认开启的思考模式在工具调用时触发的bug

1 participant