Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/agent/src/anthropic_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
for OpenAI, rejected by langchain-anthropic.
2. Streaming a zero-length thinking block accumulates {type, signature} with
no "thinking" key; Anthropic 400s on replay in the tool loop unless the
empty text field is restored."""
empty text field is restored. A persisted null text value needs the same repair."""

from langchain.agents.middleware import AgentMiddleware, ModelRequest
from langchain_core.messages import AIMessage
Expand All @@ -19,13 +19,13 @@ def _repaired_message(message):
if not isinstance(message, AIMessage) or not isinstance(message.content, list):
return message
if not any(
isinstance(b, dict) and b.get("type") == "thinking" and "thinking" not in b
isinstance(b, dict) and b.get("type") == "thinking" and b.get("thinking") is None
for b in message.content
):
return message
content = [
{**b, "thinking": ""}
if isinstance(b, dict) and b.get("type") == "thinking" and "thinking" not in b
if isinstance(b, dict) and b.get("type") == "thinking" and b.get("thinking") is None
else b
for b in message.content
]
Expand Down
41 changes: 41 additions & 0 deletions apps/agent/tests/test_anthropic_null_thinking_regression.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import asyncio

import pytest
from anthropic.types.thinking_block import ThinkingBlock
from langchain.agents.middleware import ModelRequest
from langchain_anthropic import ChatAnthropic
from langchain_core.messages import AIMessage, HumanMessage

from src.anthropic_compat import ConsecutiveSystemMessagesMiddleware


@pytest.mark.parametrize("use_async", [False, True])
def test_null_thinking_replays_as_valid_sdk_text_without_mutating_history(use_async):
model = ChatAnthropic(model="claude-sonnet-4-5-20250929", api_key="test-key")
original = AIMessage(id="replayed", content=[
{"type": "thinking", "thinking": None, "signature": "signature", "cache_control": {"type": "ephemeral"}},
{"type": "text", "text": "Ready"},
])
request = ModelRequest(model=model, messages=[HumanMessage(content="Continue"), original])
middleware = ConsecutiveSystemMessagesMiddleware()

def verify(repaired):
payload = model._get_request_payload(repaired.messages)
block = payload["messages"][-1]["content"][0]
ThinkingBlock.model_validate(block)
assert block["thinking"] == ""
assert block["signature"] == "signature"
assert block["cache_control"] == {"type": "ephemeral"}
assert repaired.messages[-1].id == "replayed"
assert repaired.messages[-1].content[1] == original.content[1]
return "verified"

async def averify(repaired):
return verify(repaired)

if use_async:
result = asyncio.run(middleware.awrap_model_call(request, averify))
else:
result = middleware.wrap_model_call(request, verify)
assert result == "verified"
assert original.content[0]["thinking"] is None