Skip to content

fix(anthropic): round-trip thinking blocks through opaque_body (issue #3658) - #3715

Closed
Harsh23Kashyap wants to merge 1 commit into
langgenius:mainfrom
Harsh23Kashyap:fix/anthropic-thinking-blocks-opaque-body
Closed

Harsh23Kashyap wants to merge 1 commit into
langgenius:mainfrom
Harsh23Kashyap:fix/anthropic-thinking-blocks-opaque-body

Conversation

@Harsh23Kashyap

Copy link
Copy Markdown
Contributor

Summary

Fixes issue #3658: Anthropic thinking blocks (thinking and redacted_thinking) were not preserved across plugin invocation boundaries on tool-result turns, causing HTTP 400 invalid_request_error responses on the second request of every tool loop.

The pre-fix code stored the blocks on the model instance (self.previous_thinking_blocks), but dify_plugin creates a fresh model instance per RPC, so the state was always empty by the time the follow-up request was built. This PR stores the blocks in AssistantPromptMessage.opaque_body, which IS preserved across invocations because it's part of the persisted message — the same pattern models/moonshot already uses for round-tripping provider-specific reasoning state.

3 files, +330/-9, manifest 0.3.27 → 0.3.28.

What's in this PR

Source change (models/anthropic/models/llm/llm.py)

Three focused edits:

  1. _handle_chat_generate_response (non-streaming): when the response contained thinking or redacted_thinking blocks, attach both lists to the returned AssistantPromptMessage.opaque_body under namespaced keys.

  2. _handle_chat_generate_stream_response (streaming): same, but on the final assistant message chunk (the one that carries the tool calls).

  3. _process_assistant_message: read from message.opaque_body first; fall back to self.previous_thinking_blocks / self.previous_redacted_thinking_blocks for the legacy case where the caller hand-crafts an assistant message without opaque_body (e.g. tests).

Tests (models/anthropic/tests/test_thinking_blocks_opaque_body.py, 10 tests)

Test class What it pins
TestProcessAssistantMessageReadsOpaqueBody 8 tests: thinking/redacted/both from opaque_body; empty opaque_body falls back to instance state; opaque_body takes precedence; no-tool case; non-dict and missing-key fallbacks
TestRoundTripProperty 2 tests: full round-trip across a fresh instance; no-op when no thinking blocks were captured

Verification

cd models/anthropic && uv run pytest tests/
# 37 passed (27 existing + 10 new), 14 skipped, no regressions

cd models/anthropic && uv run pytest tests/test_thinking_blocks_opaque_body.py -v
# 10 passed in 0.44s

cd models/anthropic && uv run ruff check tests/test_thinking_blocks_opaque_body.py
# All checks passed!

Why this matters

The bug affected:

  • Agent apps / Agent nodes with model-level function calling (tool-result turn at the Messages API level):

    • Classic extended thinking (4.x, budget_tokens) + tools → deterministic failure on the second request of every tool loop.
    • 5-series with thinking_display=summarized + tools → deterministic.
    • 5-series defaults (adaptive + omitted) → intermittent: only turns where adaptive thinking actually fired emit the block that must be echoed; turns without thinking pass, masking the bug.
  • Workflows that call tools via tool nodes (no model-level function-calling loop) are unaffected — there is no tool-result turn at the API level. This is why the issue went unnoticed in many deployments.

Backward compatibility

  • The opaque_body field is already part of AssistantPromptMessage (added in dify_plugin 0.10.x); this PR just uses it.
  • self.previous_thinking_blocks and self.previous_redacted_thinking_blocks are preserved as the fallback path — callers that don't use opaque_body (e.g. tests, custom integrations) keep working.
  • No existing tests modified; no existing behavior changed for users not running tool-use loops with thinking-capable Claude models.

Risks

Negligible. The change is additive: the new path is preferred, the old path still works.

Future work (from #3658)

This PR fixes the in-memory agent loop case. The fully robust fix likely needs Dify core to persist provider thinking state alongside message history (e.g. the core prompt-save utility serializes role/text/files/tool_calls but the issue body notes it did not appear to include opaque_body). The plugin-side fix here solves the common in-memory case; the core fix is a separate concern for the Dify core team.

Fixes #3658.

…anggenius#3658)

The pre-fix code stored Claude's `thinking` and `redacted_thinking`
blocks on the model instance (`self.previous_thinking_blocks`).
Because dify_plugin creates a fresh model instance per RPC, that
state was always empty by the time the follow-up request was
constructed. On tool-result turns, the assistant message was sent
without the thinking blocks that Anthropic requires to be echoed
back unmodified — and the API rejected with HTTP 400
`thinking or redacted_thinking blocks in the latest assistant
message cannot be modified`.

This PR stores the thinking blocks in
`AssistantPromptMessage.opaque_body`, which IS preserved across
invocations because it's part of the persisted message. The
follow-up request's `_process_assistant_message` reads them back
from `opaque_body` first, falling back to the legacy in-memory
instance state for callers that hand-craft assistant messages
without `opaque_body` (e.g. tests).

Changes:
- `_handle_chat_generate_response` (non-streaming): when
  `previous_thinking_blocks` or `previous_redacted_thinking_blocks`
  is non-empty, attach both lists to the returned
  `AssistantPromptMessage.opaque_body` under namespaced keys
  (`anthropic_thinking_blocks`, `anthropic_redacted_thinking_blocks`).
- `_handle_chat_generate_stream_response` (streaming): same, but on
  the final assistant message chunk.
- `_process_assistant_message`: read from `message.opaque_body`
  first, fall back to `self.previous_thinking_blocks` /
  `self.previous_redacted_thinking_blocks` for the legacy case.

This addresses the in-memory agent-loop case from issue langgenius#3658. The
core-persistence case (chat reload, workflow pause/resume,
`opaque_body` round-trip through Dify's message-save utility) is
out of scope for a plugin-side fix and is documented in the issue
body as the next layer of work.

10 new tests in `tests/test_thinking_blocks_opaque_body.py` cover:
- All 3 message types (thinking, redacted_thinking, both) are read
  from `opaque_body` correctly.
- `opaque_body` takes precedence over instance state.
- Instance state is used when `opaque_body` is empty / missing /
  non-dict / has missing keys.
- No-op for messages without tool follow-ups.
- Full round-trip: response handler stores blocks, follow-up
  instance reads them back.

Manifest 0.3.27 -> 0.3.28.

Fixes langgenius#3658.
@hpiclaranet

Copy link
Copy Markdown
Contributor

Core-side progress + contract notes for this PR (cross-linking the coordinated work):

Core side is now in review:

  • langgenius/graphon#261 — adds opaque_body to graphon's AssistantPromptMessage/PromptMessageContent and preserves it through the result accumulators (without this, the daemon→core Pydantic boundary silently drops the field).
  • langgenius/dify#41095 (draft) — FunctionCallAgentRunner captures opaque_body from LLM results and replays it in the next iteration's assistant prompt.
  • Tracking: langgenius/dify#41092, langgenius/graphon#260.

Contract this PR needs to satisfy for the round trip to close end-to-end:

  1. Complete-snapshot semantics. Core's runner uses "last non-None wins" capture: in streaming mode the complete opaque_body (full ordered assistant block sequence, not incremental fragments) must be attached to a stream chunk (the terminal chunk is the natural choice); in blocking mode it must be on the result message. None on other chunks is fine and never clobbers a captured snapshot.
  2. Think-tag stripping on reconstruction. When the plugin rebuilds provider messages from the assistant turn that came back through the core loop, its content contains the flattened think…think display text. If opaque_body carries the signed thinking blocks, those blocks must not also be re-emitted as text — otherwise Anthropic sees a modified assistant message (extra text block) and returns 400. The official moonshot plugin handles this by extracting/stripping reasoning from the flattened content before building the request; the same treatment is needed here.
  3. Ordering. The opaque payload is the authoritative ordered block sequence (thinking / text / tool_use / redacted_thinking), since Anthropic supports interleaved thinking between tool calls; a bare reasoning list is not sufficient.

Deployment ordering is safe in both directions (core first = no behavior change; plugin first = field silently dropped as today), so these can land independently. Happy to coordinate on the stream-chunk attachment details if useful.

@crazywoola

Copy link
Copy Markdown
Member

Hi @Harsh23Kashyap, thanks for opening this issue.

Why this is being closed

This pull request is part of a large batch of submissions opened or reopened within a short period. This submission pattern does not meet our contribution and review standards: it creates a disproportionate review burden and prevents maintainers from assessing each change with the care it deserves. Please avoid opening multiple unrelated or repeated pull requests at the same time.

Next steps

Please work on one clearly scoped item at a time. Start with the single highest-priority change, follow the pull request template in full, link a relevant issue, and wait until its review is completed before submitting the next item.

We appreciate your interest in contributing and welcome focused, well-prepared submissions that meet the repository standards.

@crazywoola crazywoola closed this Aug 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working size:M This PR changes 30-99 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

anthropic: thinking blocks are not preserved across plugin invocation boundaries on tool-result turns

3 participants