Claude extended thinking: signed/encrypted thinking blocks are dropped between Agent tool-use rounds, causing 400 errors
- Dominant language
- TypeScript
- Stars
- 156k
- Forks
- 24.6k
- Avg merge
- 22h 9m
- Merged PRs (30d)
- 610
Description
### Self Checks
- [x] I have read the [Contributing Guide](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md) and [Language Policy](https://github.com/langgenius/dify/issues/1542).
- [x] This is only for bug report, if you would like to ask a question, please head to [Discussions](https://github.com/langgenius/dify/discussions/categories/general).
- [x] I have searched for existing issues [search for existing issues](https://github.com/langgenius/dify/issues), including closed ones.
- [x] I confirm that I am using English to submit this report, otherwise it will be closed.
- [x] 【中文用户 & Non English User】请使用英语提交,否则会被关闭 :)
- [x] Please do not modify this template :) and fill in all the required fields.
### Dify version
1.16.1
### Cloud or Self Hosted
- [x] Cloud
- [x] Self Hosted (Docker)
- [x] Self Hosted (Source)
*Expected to apply to all deployments running the plugin daemon (code-path inference; no per-deployment reproduction performed).*
### Steps to reproduce
*Scope note: this report is based on code-path analysis rather than a live-deployment reproduction. It was verified against the cited sources — `dify_plugin` SDK 0.10.0, the official `anthropic` plugin v0.3.27, current Dify core / graphon sources, and the official Anthropic API documentation — and applies to releases built from the same code paths.*
1. Use an official Anthropic plugin model with thinking. No user configuration is needed to reach this state on the current model generation — per the [official docs](https://platform.claude.com/docs/en/build-with-claude/thinking#configuring-thinking), "On Claude Opus 5, Claude Sonnet 5, Claude Fable 5, Claude Mythos 5, and Claude Mythos Preview, thinking is already on: no configuration needed" (the failure still only manifests on turns where the model actually emits a thinking block — adaptive thinking may skip it on simple requests).
2. Create an **Agent** app using the function-calling strategy with at least one tool — or a workflow **Agent** node — such that a request can require more than one LLM round (assistant `tool_use` → tool result → next LLM call).
3. Run a query that forces a tool call.
4. First LLM call: the API returns `thinking` (and possibly `redacted_thinking`) + `tool_use` blocks. This round succeeds.
5. The core re-issues the second LLM call with the tool result. The replayed assistant turn is rebuilt from aggregated text (see root cause, gap 3). Depending on the path, what "survives" as plain text differs — but in **all** cases the original signed `thinking` / encrypted `redacted_thinking` content blocks (with their `signature` / `data` fields) are omitted, which the API requires:
- **streaming, summarized display:** the plugin emits thinking deltas as ordinary assistant text wrapped in `` markers; `redacted_thinking` blocks are dropped entirely (their encrypted `data` payload is discarded — see gap 2); the core concatenates any emitted strings into a plain `text` block;
- **streaming, `display: "omitted"`:** the API returns a regular `thinking` block with empty `thinking` text and a `signature`; the stream emits the block-start event and a `signature_delta`, but no thinking text. The plugin captures the signature only in instance state (gap 1), so the core replays neither the block nor its signature;
- **non-streaming:** the plugin copies only `text` blocks into the returned message content; thinking/redacted blocks are not represented in that message — they are retained only in per-instance state, which the next invocation does not share.
6. The API rejects the request.
### ✔️ Expected Behavior
The agent loop completes. The Anthropic API requires that, within a tool-use turn, thinking blocks be passed back "complete and unmodified" alongside the `tool_use` blocks they accompanied — the official docs ([Thinking — Preserving thinking blocks](https://platform.claude.com/docs/en/build-with-claude/thinking#preserving-thinking-blocks)) state:
> **Required:** within a tool-use turn, pass thinking blocks back.
Each thinking block carries a `signature` field — "an encrypted copy of the full reasoning that you pass back unchanged in multi-turn and tool-use conversations" — and `redacted_thinking` blocks carry an opaque encrypted `data` field with the same requirement.
### ❌ Actual Behavior
When (a) the preceding assistant turn emitted a `thinking` or `redacted_thinking` block and (b) the next request is the tool-result continuation of that turn, the second LLM call fails with a 400 `invalid_request_error` whose message contains (per the [official troubleshooting page](https://platform.claude.com/docs/en/build-with-claude/thinking-troubleshooting#error-thinking-blocks-modified)):
> `thinking` or `redacted_thinking` blocks in the latest assistant message cannot be modified
Multi-round tool use with thinking enabled is effectively unusable (for adaptive-thinking models the failure is intermittent — it depends on whether the model emitted a thinking block that turn).
From the same docs: "Mid-turn conflicts degrade gracefully" applies only to explicitly toggling the top-level `thinking` parameter mid-turn — it does **not** apply to a tool-result request that replays an assistant turn with missing/modified signed blocks; that is the hard 400 above.
### Root cause (code level)
Three separate loss points — two in the official Anthropic plugin, one in the core (schema, transport, and agent loop). The plugin already contains re-attachment logic, but it is structurally dead, and even a fixed plugin has nowhere to carry the blocks:
**Gap 1 (plugin): per-turn reasoning state is kept on instance state of a class the SDK instantiates statelessly.** The plugin SDK creates a **fresh model instance for every LLM call** — `dify_plugin/core/model_factory.py` (SDK 0.10.0):
```python
class ModelFactory:
"""
Model factory
Given provider configurations and its model list, generate stateless
model instances.
"""
def get_instance(self, model_type: ModelType) -> AIModel:
return self.models[model_type](self.provider.models)
```
`PluginRegistration.get_model_instance()` (same SDK, `core/plugin_registration.py`) simply delegates to this factory on every request. The official Anthropic plugin nevertheless stores the response's thinking blocks in instance state (`models/llm/llm.py`, v0.3.27):
- `__init__` initializes `self.previous_thinking_blocks = []` / `self.previous_redacted_thinking_blocks = []` (lines 218–219);
- the non-stream handler stores them from the response content (lines 945–954); the stream handler stores them at `message_stop` when tool calls are present (lines 1225–1228);
- `_process_assistant_message` re-attaches them to the assistant turn **only when the prompt contains tool messages** (lines 1486–1501):
```python
has_tool_messages = any(isinstance(msg, ToolPromptMessage) for msg in all_messages)
if has_tool_messages:
content.extend(self.previous_thinking_blocks)
content.extend(self.previous_redacted_thinking_blocks)
```
But the tool-result round (call N+1) executes on a **different instance**, so both lists are always `[]` there. The state is written on an instance that is discarded and read on a fresh one — the mechanism cannot fire. (Caching instances per conversation would not fix this: the SDK documents instances as stateless, and instances are not scoped per conversation.)
**Gap 2 (plugin, latent until gap 1 is fixed): the `redacted_thinking` `data` payload is dropped in the stream handler.** `redacted_thinking` blocks arrive on the `content_block_start` event (as `RedactedThinkingBlock` with an encrypted `data` field), but the handler records only the type (lines 1099–1101):
```python
elif getattr(content_block, "type", None) == "redacted_thinking":
current_redacted_thinking_blocks.append({
"type": "redacted_thinking"
})
```
The `data` field is never captured. (The plugin's separate `redacted_thinking` delta branch at lines 1169–1172, which would emit a human-facing placeholder string, is unreachable with the pinned anthropic SDK: `RawContentBlockDelta` is `Union[TextDelta, InputJSONDelta, CitationsDelta, ThinkingDelta, SignatureDelta]` — there is no redacted delta variant.) Even with working re-attachment, such blocks could not be round-tripped, because the docs require the original `data` value to be passed back unchanged.
**Gap 3 (core schema, transport + agent loop): no end-to-end transport for signed reasoning blocks between LLM calls.** The plugin SDK declares `opaque_body: JsonValue | None` on `AssistantPromptMessage` — a forward-compatible provider-state slot (dify-plugin-sdks PR #284, dify-plugin-daemon PR #585) — but the Dify core runtime does not carry it:
- `fc_agent_runner.py` imports `AssistantPromptMessage` from `graphon.model_runtime.entities` (core pins `graphon==0.7.0` in `api/pyproject.toml`), and the graphon v0.7.0 model has **no `opaque_body` field** — so the field is dropped when plugin results are deserialized in core, before the agent loop ever sees it;
- even if it arrived, the agent runner rebuilds each assistant turn from aggregated string + tool_calls only, and never propagates provider state (lines 212–218 / 244–248 aggregate; lines 265–278 rebuild):
```python
# streamed / blocking deltas are aggregated into a plain string (lines 212–218 / 244–248)
response += str(chunk.delta.message.content)
...
# the assistant turn is then rebuilt from string + tool_calls only (lines 265–278)
assistant_message = AssistantPromptMessage(content=response, tool_calls=[])
if tool_calls:
assistant_message.tool_calls = [...]
self._current_thoughts.append(assistant_message)
```
- `base_agent_runner.py` reconstructs historical assistant messages from `agent_thought.thought` + tool calls in the same way.
No agent-loop path propagates the field. Tellingly, the official `moonshot` plugin — which does populate `opaque_body` with `reasoning_content` — ships a regex fallback that re-parses reasoning from the flattened text content whenever `opaque_body` is unavailable — the same loss documented in this gap. Consequently, in the streaming (summarized) path the only "thinking" that survives into round N+1 is the summary text flattened into ordinary assistant `text` content; in the non-streaming path nothing survives. The signed `thinking` / encrypted `redacted_thinking` blocks are lost by construction in both paths.
### Related work
The plugin-side instance-state defect (gap 1) is already tracked in [dify-official-plugins#3658](https://github.com/langgenius/dify-official-plugins/issues/3658), and the plugin-side `opaque_body` mechanism for the in-memory case is proposed in the open [dify-official-plugins#3715](https://github.com/langgenius/dify-official-plugins/pull/3715) (the same pattern is already in production in the official `moonshot` plugin). That plugin-side work is **necessary but not sufficient**: #3715 is not yet the complete ordered, data-preserving contract described below, and it cannot work end-to-end unless the core runtime (graphon + agent runner, gap 3) actually carries `opaque_body` across the plugin-result boundary and into the next prompt. This issue tracks that remaining core-side work.
### Requested change
Persist and pass through assistant-turn reasoning blocks across LLM calls.
**Phase 1 — in-memory agent-loop bugfix (the 400 itself):**
1. **Cross-repo message/transport contract (dify-plugin-sdks + graphon + Dify core):** preserve an optional provider-state field on assistant messages end-to-end. The plugin SDK already exposes `AssistantPromptMessage.opaque_body`; graphon's `AssistantPromptMessage` (and the `LLMResult` / `LLMResultChunkDelta.message` models built on it) do not yet. Extend the runtime schema and the daemon→core deserialization path so `opaque_body` survives both blocking results and the terminal stream chunk. Define the payload as the **complete ordered sequence of provider assistant content blocks as returned** (e.g. `thinking` → `text` → `tool_use` in their original positions) — not a bare list of reasoning blocks, since Anthropic supports interleaved thinking between tool calls and a list without ordering cannot round-trip interleaved turns:
```json
{
"assistant_blocks": [
{"type": "thinking", "thinking": "...", "signature": "..."},
{"type": "text", "text": "..."},
{"type": "tool_use", "id": "toolu_...", "name": "...", "input": {}},
{"type": "redacted_thinking", "data": "..."}
]
}
```
2. **Core, in-memory agent loop:** `fc_agent_runner.py` must preserve and propagate the provider state from the LLM result / terminal stream chunk into `_current_thoughts` for the next iteration.
**Phase 2 — persistence for resumed conversations:**
3. **Core, conversation memory:** persist the provider state on assistant messages (the core prompt-save utility currently serializes role/text/files/tool_calls only) and reconstruct it when a conversation is resumed.
**Plugin side (coordinated with dify-official-plugins#3715, not part of this repo's ask):** the final plugin implementation should store the complete ordered block sequence (including `redacted_thinking.data` — see gap 2) rather than type-only entries.
Design considerations: stale blocks must be dropped when the user switches models mid-conversation — per the Anthropic docs, "Thinking blocks are tied to the model that produced them. Other models silently ignore them rather than rejecting the request, but ignored blocks still add input tokens." The behavior should also be specified for the `display: "omitted"` / summarized modes. Suggested test matrix: stream + non-stream, redacted blocks, omitted display, interleaved / multiple tool calls (ordering preservation), resumed history, model switch.
### Workaround
For models that allow it, disabling thinking is the in-place workaround. For always-on model generations (e.g. Fable 5, Mythos 5) there is no configuration-only workaround while retaining that model/provider for multi-round tool use; switching models/providers or avoiding the multi-round tool loop are the only alternatives.
### Impact
- Official Anthropic plugin: any affected Claude model/turn that emits `thinking` or `redacted_thinking` blocks and enters a tool-result round — Agent (function-calling) or workflow Agent node.
- Any future provider whose API requires signed reasoning blocks to be echoed back will hit the same wall; the passthrough in core/SDK benefits all such providers.
Contributor guide
Research direction
Start with fc_agent_runner.py and base_agent_runner.py, then inspect graphon’s AssistantPromptMessage and the daemon-to-core deserialization path described in the issue. Trace both blocking and terminal streaming LLM results through the agent loop; done means provider reasoning state, including signed or encrypted blocks, survives the boundary and is replayed unchanged on the tool-result call.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- ai, backend-api-design
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 30/100