From 664e53154469b0bb8b6d045cbdf70e228cbbf644 Mon Sep 17 00:00:00 2001 From: Echolonius <273862755+Echolonius@users.noreply.github.com> Date: Thu, 14 May 2026 00:12:26 -0500 Subject: [PATCH] fix: only suppress auto-append when caller explicitly provides a tool result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, any call to append_messages() — even adding a plain user instruction like "be concise" — set _messages_modified=True, which told the runner to skip its own auto-append of the assistant message and tool result. The next iteration would send a request with no tool result in history, Claude would see the original question unanswered, re-issue the same tool call, and the loop would never terminate. The fix makes the flag conditional: _messages_modified is only set when at least one appended message contains a tool_result content block — the clear signal that the caller is handling the tool result themselves. Appending other messages (extra instructions, context, etc.) leaves the flag untouched and the loop continues correctly. This also aligns with the inconsistency noted in the docs: the "Advanced usage" example appends a user message without a tool result, while the "Modifying tool results" example correctly passes both the assistant message and tool result together. After this fix, both patterns work. Fixes #1536. --- src/anthropic/lib/tools/_beta_runner.py | 22 ++++++++++- tests/lib/tools/test_runners.py | 50 +++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/src/anthropic/lib/tools/_beta_runner.py b/src/anthropic/lib/tools/_beta_runner.py index 52e48698..cc9a0b76 100644 --- a/src/anthropic/lib/tools/_beta_runner.py +++ b/src/anthropic/lib/tools/_beta_runner.py @@ -111,12 +111,32 @@ def append_messages(self, *messages: BetaMessageParam | ParsedBetaMessage[Respon This invalidates the cached tool response, i.e. if tools were already called, then they will be called again on the next loop iteration. + + If any of the appended messages contain a ``tool_result`` content block, the runner treats + this as the caller manually handling the tool response and will skip its own auto-append of + the assistant message and tool result for that iteration. Appending messages that do *not* + contain tool results (e.g. an extra user instruction) leaves the auto-append behaviour + unchanged so the loop continues correctly. """ message_params: List[BetaMessageParam] = [ {"role": message.role, "content": message.content} if isinstance(message, BetaMessage) else message for message in messages ] - self._messages_modified = True + # Only suppress the runner's auto-append when the caller is explicitly providing a tool + # result. Previously this flag was always set, which caused an infinite loop when callers + # appended non-tool-result messages (e.g. extra instructions) inside the loop because the + # tool result was never added to the conversation history. + has_tool_result = any( + isinstance(msg, dict) + and isinstance(msg.get("content"), list) + and any( + isinstance(block, dict) and block.get("type") == "tool_result" + for block in msg.get("content", []) + ) + for msg in message_params + ) + if has_tool_result: + self._messages_modified = True self.set_messages_params(lambda params: {**params, "messages": [*params["messages"], *message_params]}) self._cached_tool_call_response = None diff --git a/tests/lib/tools/test_runners.py b/tests/lib/tools/test_runners.py index 225fd2b5..39573daa 100644 --- a/tests/lib/tools/test_runners.py +++ b/tests/lib/tools/test_runners.py @@ -235,6 +235,56 @@ def get_weather(location: str, units: Literal["c", "f"]) -> BetaFunctionToolResu """ ) + def test_append_messages_non_tool_result_does_not_suppress_auto_append(self) -> None: + """Appending a plain user message must not suppress the runner's auto-append of tool results. + + Regression test for https://github.com/anthropics/anthropic-sdk-python/issues/1536 + + Before the fix, *any* call to append_messages() set _messages_modified=True, which caused + the runner to skip auto-appending the assistant message + tool result. The next iteration + therefore sent a request with no tool result in history, Claude saw the original question + unanswered, made the same tool call again, and the loop never terminated. + """ + from unittest.mock import MagicMock + + from anthropic.lib.tools._beta_runner import BetaToolRunner + + mock_client = MagicMock(spec=Anthropic) + runner: BetaToolRunner[None] = BetaToolRunner( + params={ + "model": "claude-haiku-4-5", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "What's the weather in SF?"}], + }, + options={}, + tools=[], + client=mock_client, + ) + + assert runner._messages_modified is False + + # A plain user instruction must NOT flip the flag — the runner still needs to + # auto-append the tool result for the loop to terminate. + runner.append_messages({"role": "user", "content": "Please be concise."}) + assert runner._messages_modified is False, ( + "append_messages() with a non-tool-result message set _messages_modified=True, " + "which would suppress the runner's auto-append and cause an infinite loop (issue #1536)" + ) + + # Manually providing a tool result MUST flip the flag so the runner skips its own + # auto-append (the caller is handling the tool result themselves). + runner._messages_modified = False # reset for this sub-check + runner.append_messages( + { + "role": "user", + "content": [{"type": "tool_result", "tool_use_id": "fake_id", "content": "sunny"}], + } + ) + assert runner._messages_modified is True, ( + "append_messages() with a tool_result message must set _messages_modified=True " + "so the runner skips its own auto-append" + ) + @pytest.mark.parametrize( "http_snapshot", [