From 3122125fd0ce183d066bb8d9109f3e5496e32ecf Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 21:16:53 -0400 Subject: [PATCH] fix(agent): normalize null thinking text before Anthropic replay --- apps/agent/src/anthropic_compat.py | 6 +-- ...test_anthropic_null_thinking_regression.py | 41 +++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 apps/agent/tests/test_anthropic_null_thinking_regression.py diff --git a/apps/agent/src/anthropic_compat.py b/apps/agent/src/anthropic_compat.py index 5417650..17f2b1c 100644 --- a/apps/agent/src/anthropic_compat.py +++ b/apps/agent/src/anthropic_compat.py @@ -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 @@ -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 ] diff --git a/apps/agent/tests/test_anthropic_null_thinking_regression.py b/apps/agent/tests/test_anthropic_null_thinking_regression.py new file mode 100644 index 0000000..c876be8 --- /dev/null +++ b/apps/agent/tests/test_anthropic_null_thinking_regression.py @@ -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