mirror of
https://github.com/hwchase17/langchain.git
synced 2026-07-12 11:21:37 +00:00
fix(core): preserve reasoning block extras across stream deltas
Anthropic's thinking stream emits a `signature_delta` after the
reasoning text finishes. The adapter surfaces this as a reasoning
delta carrying `extras.signature` (and no new text). Two places
were dropping those fields while assembling the accumulated block:
- `_compat_bridge._accumulate` only concatenated the `reasoning`
text, silently discarding any other keys (including `extras`) on
later deltas.
- `chat_model_stream._push_content_block_finish` rebuilt the
finalized reasoning block as `{"type": "reasoning", "reasoning": ...}`,
dropping everything the finish event carried.
Together, these stripped Claude's `extras.signature` from the
assembled `AIMessage`, and the next turn in a `create_agent` loop
failed with `messages.<n>.content.<k>.thinking.signature: Field
required`.
The bridge now merges `extras` (so earlier keys survive later
deltas) and replaces other non-text fields; `ChatModelStream`
spreads the incoming finish block before overwriting the two
fields it owns.
Covered by the new
`test_lifecycle_validator_anthropic_reasoning_preserves_signature`
case.
This commit is contained in:
@@ -258,6 +258,17 @@ def _accumulate(state: CompatBlock | None, delta: CompatBlock) -> CompatBlock:
|
||||
state["text"] = state.get("text", "") + delta.get("text", "")
|
||||
elif btype == "reasoning" and dtype == "reasoning":
|
||||
state["reasoning"] = state.get("reasoning", "") + delta.get("reasoning", "")
|
||||
# Providers may ship non-text fields on later deltas. Claude's
|
||||
# `signature_delta` arrives after the reasoning text, surfaced
|
||||
# as `extras.signature`; merging (not replacing) keeps earlier
|
||||
# keys intact.
|
||||
for key, value in delta.items():
|
||||
if key in ("type", "reasoning") or value is None:
|
||||
continue
|
||||
if key == "extras" and isinstance(value, dict):
|
||||
state["extras"] = {**(state.get("extras") or {}), **value}
|
||||
else:
|
||||
state[key] = value
|
||||
elif btype in ("tool_call_chunk", "server_tool_call_chunk") and dtype == btype:
|
||||
state["args"] = state.get("args", "") + (delta.get("args") or "")
|
||||
if delta.get("id") is not None:
|
||||
|
||||
@@ -703,9 +703,16 @@ class ChatModelStream:
|
||||
full_r = reasoning_block.get("reasoning", "")
|
||||
if full_r and full_r != self._reasoning_acc:
|
||||
self._reasoning_acc = full_r
|
||||
# Keep provider-specific fields alongside the accumulated
|
||||
# reasoning text. Anthropic's `signature` arrives under
|
||||
# `extras` and is required on follow-up turns.
|
||||
finalized = cast(
|
||||
"FinalizedContentBlock",
|
||||
{"type": "reasoning", "reasoning": self._reasoning_acc},
|
||||
{
|
||||
**reasoning_block,
|
||||
"type": "reasoning",
|
||||
"reasoning": self._reasoning_acc,
|
||||
},
|
||||
)
|
||||
elif btype == "tool_call":
|
||||
tcb = cast("ToolCallBlock", block)
|
||||
|
||||
@@ -814,6 +814,45 @@ def test_lifecycle_validator_anthropic_style_text_and_thinking() -> None:
|
||||
assert finishes[1]["content_block"]["text"] == "The answer is 42."
|
||||
|
||||
|
||||
def test_lifecycle_validator_anthropic_reasoning_preserves_signature() -> None:
|
||||
"""A later reasoning delta's `extras.signature` must land on the finish block.
|
||||
|
||||
Anthropic emits reasoning content as `thinking_delta` events (text),
|
||||
followed by a `signature_delta` event carrying the cryptographic
|
||||
signature that the API requires on any follow-up turn. After the
|
||||
content-block-start/delta translation, that signature arrives as
|
||||
`extras.signature` on a reasoning delta that has no new text. If
|
||||
the bridge drops it, Claude rejects the next request with
|
||||
`messages.<n>.content.<k>.thinking.signature: Field required`.
|
||||
"""
|
||||
chunks = [
|
||||
_aimsg_chunk([{"type": "reasoning", "reasoning": "Let me think", "index": 0}]),
|
||||
_aimsg_chunk([{"type": "reasoning", "reasoning": " more", "index": 0}]),
|
||||
# signature_delta arrives after the text; no new reasoning text
|
||||
# but carries the signature under `extras`.
|
||||
_aimsg_chunk(
|
||||
[
|
||||
{
|
||||
"type": "reasoning",
|
||||
"reasoning": "",
|
||||
"index": 0,
|
||||
"extras": {"signature": "sig-abc123"},
|
||||
}
|
||||
]
|
||||
),
|
||||
_aimsg_chunk([{"type": "text", "text": "Hi.", "index": 1}]),
|
||||
]
|
||||
|
||||
events = list(chunks_to_events(iter(chunks), message_id="m"))
|
||||
assert_valid_event_stream(events)
|
||||
|
||||
finishes: list[Any] = [e for e in events if e["event"] == "content-block-finish"]
|
||||
reasoning_finish = finishes[0]["content_block"]
|
||||
assert reasoning_finish["type"] == "reasoning"
|
||||
assert reasoning_finish["reasoning"] == "Let me think more"
|
||||
assert reasoning_finish.get("extras", {}).get("signature") == "sig-abc123"
|
||||
|
||||
|
||||
def test_lifecycle_validator_anthropic_style_tool_use_after_text() -> None:
|
||||
"""Text then tool_use (tool_call_chunk) — Anthropic tool-calling pattern."""
|
||||
chunks = [
|
||||
|
||||
Reference in New Issue
Block a user