Skip to content

Claude extended thinking: signed/encrypted thinking blocks are dropped between Agent tool-use rounds, causing 400 errors #41092

Open

Description

@hpiclaranet

Self Checks

  • I have read the Contributing Guide and Language Policy.
  • This is only for bug report, if you would like to ask a question, please head to Discussions.
  • I have searched for existing issues search for existing issues, including closed ones.
  • I confirm that I am using English to submit this report, otherwise it will be closed.
  • 【中文用户 & Non English User】请使用英语提交,否则会被关闭 :)
  • Please do not modify this template :) and fill in all the required fields.

Delivery status for Dify 1.19.0 — 2026-11-02

This remains open for the complete Agent replay rollout.
The reproduction and root-cause sections below describe the original affected versions; implementation has progressed as follows.

Component Current status Remaining delivery
SDK langgenius/dify-plugin-sdks#390 merged; SDK public version remains 0.10.2 Publish SDK 0.11.0, including the reviewed langgenius/dify-plugin-sdks#412 follow-up
Graphon langgenius/graphon#261, langgenius/graphon#355 and langgenius/graphon#376 merged Refresh the host candidate, incorporate the selected langgenius/graphon#344 container changes, and record the production dependency
Dify #44502 replaces closed/unmerged #41095 and covers legacy Agents, workflow history and Agent v2 Resolve current main conflicts and complete the release gates; keep the PR in draft
Official providers/Agents langgenius/dify-official-plugins#4034 implements complete calls and signed replay; langgenius/dify-official-plugins#4156 adapts response hooks to the final SDK Reconcile the candidates, publish the SDK-based plugin archives, and set minimum Dify/daemon versions
Daemon langgenius/dify-plugin-daemon#828 remains open for lossless backwards JSON and history Coordinate the selected deployment; no 1.19 milestone assignment in this update

The Graphon, SDK, Official Plugins and Daemon numbers in this table refer to their respective repositories.
The existing host PR records controlled transport/history validation, and its current GitHub API, Agent, migration and web E2E checks have passed.
Live vendor signature acceptance and the production rollout remain unverified.

langgenius/dify-official-plugins#4034 is a prerequisite for the planned host consumer: the new collector rejects empty/repeated call IDs while existing providers such as Azure AI Studio emit argument fragments as separate calls.
Its OpenAI-compatible upgrade also needs langgenius/dify-official-plugins#4156's tools response-hook adaptation for the final SDK 0.11.
langgenius/dify-official-plugins#4033 and langgenius/dify-official-plugins#4035 remain separate OpenAI/endpoint delivery work with no independent Dify 1.19 release blocker established.

The target is the 1.19.0 milestone.

Dify version

1.16.1

Cloud or Self Hosted

  • Cloud
  • Self Hosted (Docker)
  • 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, "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 <think> 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) 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):

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.

Historical 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):

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

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):
# 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

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:

    {
      "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:

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

Activity

  1. dosubot commented on Aug 22, 2026

    @dosubot

    This is a well-researched and accurate report. Here's what I can confirm about the current state of the code and related work:

    The three gaps you identified are confirmed in the current codebase:

    1. Gap 1 (plugin instance state): The re-attachment logic in _process_assistant_message (lines 1499–1501) reads from self.previous_thinking_blocks / self.previous_redacted_thinking_blocks, but these are stored on a model instance that is discarded between RPC calls [1] [2]. This is being addressed by dify-official-plugins#3715, which persists the blocks via AssistantPromptMessage.opaque_body — the same pattern the Moonshot plugin already uses [3].

    2. Gap 2 (redacted_thinking data dropped): Confirmed — the stream handler captures only {"type": "redacted_thinking"} with no data field [4].

    3. Gap 3 (core has no opaque_body transport): This is the core-side blocker. fc_agent_runner.py imports AssistantPromptMessage from graphon.model_runtime.entities and rebuilds each assistant turn from a plain string + tool calls only — no provider state is propagated [5].

    There is an open PR — dify#31566 — authored by @BeautyyuYanli titled "feat: support opaque_body from plugin LLM calls" (part of #31557). It proposes adding opaque_body to the core message entity, capturing it during streaming, persisting it in message_metadata as assistant_opaque_body, and restoring it when rebuilding memory prompt history [6]. That PR is still open and unmerged, which is why opaque_body is absent from the current api/core tree [7].

    What still needs to happen on the core side:

    • graphon needs opaque_body on AssistantPromptMessage / LLMResult / LLMResultChunkDelta.message so the field survives daemon→core deserialization.
    • fc_agent_runner.py needs to read opaque_body from the LLM result and carry it into _current_thoughts for the next iteration (your Phase 1).
    • base_agent_runner.py needs to persist and reconstruct opaque_body when rebuilding history from agent_thought records (your Phase 2).

    PR #31566 covers most of this. You may want to coordinate with @BeautyyuYanli on that PR, since it's the direct core-side counterpart to the plugin-side #3715.

    To reply, just mention @dosu.


    Docs are dead. Just use Dosu.

    Leave Feedback Ask Dosu about dify

  2. self-assigned this
    on Sep 23, 2026
  3. added this to the 1.19.0 milestone on Nov 2, 2026
  4. modified the milestones: 1.19.0, next version on Nov 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions