From 65acc4c9d36e2f4590dc2566abf5b5103533fe85 Mon Sep 17 00:00:00 2001 From: "open-swe[bot]" <215916821+open-swe[bot]@users.noreply.github.com> Date: Thu, 28 May 2026 16:15:13 -0700 Subject: [PATCH] fix: sanitize malformed Anthropic thinking blocks (#1357) Co-authored-by: open-swe[bot] Co-authored-by: Johannes du Plessis <51395795+johannes117@users.noreply.github.com> --- AGENTS.md | 5 +- agent/middleware/__init__.py | 2 + agent/middleware/sanitize_thinking_blocks.py | 67 +++++++++++++++++ agent/reviewer.py | 2 + agent/server.py | 2 + tests/test_sanitize_thinking_blocks.py | 76 ++++++++++++++++++++ 6 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 agent/middleware/sanitize_thinking_blocks.py create mode 100644 tests/test_sanitize_thinking_blocks.py diff --git a/AGENTS.md b/AGENTS.md index 4e0f6850..7059f57f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,9 +68,10 @@ Configured in `agent/server.py:get_agent`, runs around every model call (in this 6. `ensure_no_empty_msg` — guards against empty assistant messages that some providers reject. 7. `notify_step_limit_reached` — after-agent hook that posts a Slack reply when the agent hits the step limit, so the user gets a clear signal instead of silence. 8. `SandboxCircuitBreakerMiddleware` — trips the agent out of repeated sandbox failures instead of looping. -9. `ModelFallbackMiddleware` (optional, last) — added only when `LLM_FALLBACK_MODEL_ID` or the per-model default fallback differs from the primary model. +9. `ModelFallbackMiddleware` (optional) — added only when `LLM_FALLBACK_MODEL_ID` or the per-model default fallback differs from the primary model. +10. `SanitizeThinkingBlocksMiddleware` — strips malformed empty Anthropic thinking blocks immediately before provider calls. -Other middleware exists in `agent/middleware/` (`ExcludeToolsMiddleware`) but isn't wired into the default agent. The reviewer uses a leaner stack: `SanitizeToolInputsMiddleware`, `ModelCallLimitMiddleware`, `ToolErrorMiddleware`, `SlackAssistantStatusMiddleware`. +Other middleware exists in `agent/middleware/` (`ExcludeToolsMiddleware`) but isn't wired into the default agent. The reviewer uses a leaner stack: `SanitizeToolInputsMiddleware`, `ModelCallLimitMiddleware`, `ToolErrorMiddleware`, `SlackAssistantStatusMiddleware`, `SanitizeThinkingBlocksMiddleware`. There is intentionally no after-agent safety net that opens a PR for the agent. The agent itself is responsible for committing, pushing, opening/updating the draft PR, and replying in the source channel — all via `GH_TOKEN=dummy gh` and `slack_thread_reply` / `linear_comment`. diff --git a/agent/middleware/__init__.py b/agent/middleware/__init__.py index b402bd16..bec5f345 100644 --- a/agent/middleware/__init__.py +++ b/agent/middleware/__init__.py @@ -5,12 +5,14 @@ from .model_fallback import ModelFallbackMiddleware from .notify_step_limit import notify_step_limit_reached from .refresh_slack_status import SlackAssistantStatusMiddleware from .sandbox_circuit_breaker import SandboxCircuitBreakerMiddleware +from .sanitize_thinking_blocks import SanitizeThinkingBlocksMiddleware from .sanitize_tool_inputs import SanitizeToolInputsMiddleware from .tool_error_handler import ToolErrorMiddleware __all__ = [ "ExcludeToolsMiddleware", "ModelFallbackMiddleware", + "SanitizeThinkingBlocksMiddleware", "SanitizeToolInputsMiddleware", "ToolErrorMiddleware", "SandboxCircuitBreakerMiddleware", diff --git a/agent/middleware/sanitize_thinking_blocks.py b/agent/middleware/sanitize_thinking_blocks.py new file mode 100644 index 00000000..f3b251e7 --- /dev/null +++ b/agent/middleware/sanitize_thinking_blocks.py @@ -0,0 +1,67 @@ +"""Middleware that removes malformed Anthropic thinking blocks before model calls.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from typing import Any + +from langchain.agents.middleware import AgentMiddleware +from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse +from langchain_anthropic import ChatAnthropic +from langchain_core.messages import AIMessage + + +def _is_chat_anthropic(model: object) -> bool: + seen: set[int] = set() + current = model + for _ in range(10): + if isinstance(current, ChatAnthropic): + return True + current_id = id(current) + if current_id in seen: + return False + seen.add(current_id) + bound = getattr(current, "bound", None) + if bound is None or bound is current: + return False + current = bound + return False + + +def _sanitize_messages(messages: list[Any]) -> None: + for message in messages: + if not isinstance(message, AIMessage) or not isinstance(message.content, list): + continue + content = [ + block + for block in message.content + if not ( + isinstance(block, dict) + and block.get("type") == "thinking" + and not block.get("thinking") + ) + ] + if len(content) != len(message.content): + message.content = content + + +class SanitizeThinkingBlocksMiddleware(AgentMiddleware): + """Drop empty Anthropic thinking blocks before provider validation.""" + + def wrap_model_call( + self, + request: ModelRequest, + handler: Callable[[ModelRequest], ModelResponse], + ) -> ModelCallResult: + if _is_chat_anthropic(request.model): + _sanitize_messages(request.messages) + return handler(request) + + async def awrap_model_call( + self, + request: ModelRequest, + handler: Callable[[ModelRequest], Awaitable[ModelResponse]], + ) -> Any: + if _is_chat_anthropic(request.model): + _sanitize_messages(request.messages) + return await handler(request) diff --git a/agent/reviewer.py b/agent/reviewer.py index bbfe24a9..048b2eee 100644 --- a/agent/reviewer.py +++ b/agent/reviewer.py @@ -35,6 +35,7 @@ from deepagents import create_deep_agent from langchain.agents.middleware import ModelCallLimitMiddleware from .middleware import ( + SanitizeThinkingBlocksMiddleware, SanitizeToolInputsMiddleware, SlackAssistantStatusMiddleware, ToolErrorMiddleware, @@ -805,5 +806,6 @@ async def get_reviewer_agent(config: RunnableConfig) -> Pregel: ToolErrorMiddleware(), check_message_queue_before_model, SlackAssistantStatusMiddleware(), + SanitizeThinkingBlocksMiddleware(), ], ).with_config(config) diff --git a/agent/server.py b/agent/server.py index 7dc5d0b5..fe7bb2a2 100644 --- a/agent/server.py +++ b/agent/server.py @@ -47,6 +47,7 @@ from .integrations.langsmith import _configure_github_proxy from .middleware import ( ModelFallbackMiddleware, SandboxCircuitBreakerMiddleware, + SanitizeThinkingBlocksMiddleware, SanitizeToolInputsMiddleware, SlackAssistantStatusMiddleware, ToolErrorMiddleware, @@ -518,5 +519,6 @@ async def get_agent(config: RunnableConfig) -> Pregel: notify_step_limit_reached, SandboxCircuitBreakerMiddleware(), *fallback_middleware, + SanitizeThinkingBlocksMiddleware(), ], ).with_config(config) diff --git a/tests/test_sanitize_thinking_blocks.py b/tests/test_sanitize_thinking_blocks.py new file mode 100644 index 00000000..7145fe73 --- /dev/null +++ b/tests/test_sanitize_thinking_blocks.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest +from langchain_anthropic import ChatAnthropic +from langchain_core.messages import AIMessage, HumanMessage + +from agent.middleware.sanitize_thinking_blocks import SanitizeThinkingBlocksMiddleware + + +def _make_request(messages: list[object], model: object | None = None) -> MagicMock: + request = MagicMock() + request.model = model or MagicMock(spec=ChatAnthropic) + request.messages = messages + return request + + +class TestSanitizeThinkingBlocksMiddleware: + def test_drops_empty_thinking_block_for_anthropic(self) -> None: + message = AIMessage( + content=[ + {"type": "thinking", "signature": "abc", "thinking": ""}, + {"type": "text", "text": "ok"}, + ] + ) + request = _make_request([message]) + response = MagicMock() + + def handler(req: object) -> object: + assert req is request + return response + + result = SanitizeThinkingBlocksMiddleware().wrap_model_call(request, handler) + + assert result is response + assert message.content == [{"type": "text", "text": "ok"}] + + def test_preserves_non_empty_thinking_block_for_anthropic(self) -> None: + thinking_block = {"type": "thinking", "signature": "abc", "thinking": "reasoning"} + text_block = {"type": "text", "text": "ok"} + message = AIMessage(content=[thinking_block, text_block]) + request = _make_request([message]) + + SanitizeThinkingBlocksMiddleware().wrap_model_call(request, lambda req: MagicMock()) + + assert message.content == [thinking_block, text_block] + + @pytest.mark.asyncio + async def test_async_drops_missing_thinking_block_for_anthropic(self) -> None: + message = AIMessage( + content=[ + {"type": "thinking", "signature": "abc"}, + {"type": "text", "text": "ok"}, + ] + ) + request = _make_request([HumanMessage(content="hi"), message]) + response = MagicMock() + + async def handler(req: object) -> object: + assert req is request + return response + + result = await SanitizeThinkingBlocksMiddleware().awrap_model_call(request, handler) + + assert result is response + assert message.content == [{"type": "text", "text": "ok"}] + + def test_ignores_non_anthropic_models(self) -> None: + thinking_block = {"type": "thinking", "signature": "abc", "thinking": ""} + message = AIMessage(content=[thinking_block, {"type": "text", "text": "ok"}]) + request = _make_request([message], model=MagicMock()) + + SanitizeThinkingBlocksMiddleware().wrap_model_call(request, lambda req: MagicMock()) + + assert message.content == [thinking_block, {"type": "text", "text": "ok"}]