Repository navigation
fix(anthropic): round-trip thinking blocks through opaque_body (issue #3658) - #3715
Harsh23Kashyap wants to merge 1 commit into
Conversation
…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.
|
Core-side progress + contract notes for this PR (cross-linking the coordinated work): Core side is now in review:
Contract this PR needs to satisfy for the round trip to close end-to-end:
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. |
|
Hi @Harsh23Kashyap, thanks for opening this issue. Why this is being closedThis 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 stepsPlease 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. |
Summary
Fixes issue #3658: Anthropic thinking blocks (
thinkingandredacted_thinking) were not preserved across plugin invocation boundaries on tool-result turns, causing HTTP 400invalid_request_errorresponses on the second request of every tool loop.The pre-fix code stored the blocks on the model instance (
self.previous_thinking_blocks), butdify_plugincreates 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 inAssistantPromptMessage.opaque_body, which IS preserved across invocations because it's part of the persisted message — the same patternmodels/moonshotalready 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:
_handle_chat_generate_response(non-streaming): when the response contained thinking or redacted_thinking blocks, attach both lists to the returnedAssistantPromptMessage.opaque_bodyunder namespaced keys._handle_chat_generate_stream_response(streaming): same, but on the final assistant message chunk (the one that carries the tool calls)._process_assistant_message: read frommessage.opaque_bodyfirst; fall back toself.previous_thinking_blocks/self.previous_redacted_thinking_blocksfor the legacy case where the caller hand-crafts an assistant message withoutopaque_body(e.g. tests).Tests (
models/anthropic/tests/test_thinking_blocks_opaque_body.py, 10 tests)TestProcessAssistantMessageReadsOpaqueBodyopaque_body; emptyopaque_bodyfalls back to instance state;opaque_bodytakes precedence; no-tool case; non-dict and missing-key fallbacksTestRoundTripPropertyVerification
Why this matters
The bug affected:
Agent apps / Agent nodes with model-level function calling (tool-result turn at the Messages API level):
budget_tokens) + tools → deterministic failure on the second request of every tool loop.thinking_display=summarized+ tools → deterministic.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
opaque_bodyfield is already part ofAssistantPromptMessage(added indify_plugin0.10.x); this PR just uses it.self.previous_thinking_blocksandself.previous_redacted_thinking_blocksare preserved as the fallback path — callers that don't useopaque_body(e.g. tests, custom integrations) keep working.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-saveutility serializes role/text/files/tool_calls but the issue body notes it did not appear to includeopaque_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.