From 07f9ab580ad64ec19217f8518230948d4eb5c395 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Tue, 12 May 2026 12:56:06 +0000 Subject: [PATCH] fix(provider): preserve Bedrock tool config for history Co-authored-by: Cursor --- nanobot/providers/bedrock_provider.py | 29 +++++++++++++++++++- tests/providers/test_bedrock_provider.py | 34 ++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/nanobot/providers/bedrock_provider.py b/nanobot/providers/bedrock_provider.py index 479637916..88c4ac2b2 100644 --- a/nanobot/providers/bedrock_provider.py +++ b/nanobot/providers/bedrock_provider.py @@ -18,6 +18,7 @@ _IMAGE_DATA_URL = re.compile(r"^data:image/([a-zA-Z0-9.+-]+);base64,(.*)$", re.D _TEXT_BLOCK_TYPES = {"text", "input_text", "output_text"} _TEMPERATURE_UNSUPPORTED_MODEL_TOKENS = ("claude-opus-4-7",) _ADAPTIVE_THINKING_ONLY_MODEL_TOKENS = ("claude-opus-4-7",) +_NOOP_TOOL_NAME = "nanobot_noop" def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: @@ -325,6 +326,27 @@ class BedrockProvider(LLMProvider): result.append({"toolSpec": spec}) return result or None + @staticmethod + def _contains_tool_blocks(messages: list[dict[str, Any]]) -> bool: + for msg in messages: + content = msg.get("content") + if not isinstance(content, list): + continue + for block in content: + if isinstance(block, dict) and ("toolUse" in block or "toolResult" in block): + return True + return False + + @staticmethod + def _noop_tool() -> dict[str, Any]: + return { + "toolSpec": { + "name": _NOOP_TOOL_NAME, + "description": "Internal placeholder for Bedrock tool history validation.", + "inputSchema": {"json": {"type": "object", "properties": {}}}, + } + } + @staticmethod def _convert_tool_choice( tool_choice: str | dict[str, Any] | None, @@ -389,11 +411,16 @@ class BedrockProvider(LLMProvider): kwargs["additionalModelRequestFields"] = additional bedrock_tools = self._convert_tools(tools) + tool_config: dict[str, Any] | None = None if bedrock_tools: - tool_config: dict[str, Any] = {"tools": bedrock_tools} + tool_config = {"tools": bedrock_tools} choice = self._convert_tool_choice(tool_choice) if choice: tool_config["toolChoice"] = choice + elif self._contains_tool_blocks(bedrock_messages): + tool_config = {"tools": [self._noop_tool()]} + + if tool_config: kwargs["toolConfig"] = tool_config return kwargs diff --git a/tests/providers/test_bedrock_provider.py b/tests/providers/test_bedrock_provider.py index e86b8426d..3a480ef1d 100644 --- a/tests/providers/test_bedrock_provider.py +++ b/tests/providers/test_bedrock_provider.py @@ -106,6 +106,7 @@ def test_generic_bedrock_model_keeps_temperature_and_skips_anthropic_thinking() assert kwargs["modelId"] == "amazon.nova-lite-v1:0" assert kwargs["inferenceConfig"] == {"maxTokens": 1024, "temperature": 0.3} assert "additionalModelRequestFields" not in kwargs + assert "toolConfig" not in kwargs def test_build_kwargs_converts_messages_tools_and_tool_results() -> None: @@ -160,6 +161,39 @@ def test_build_kwargs_converts_messages_tools_and_tool_results() -> None: assert kwargs["toolConfig"]["toolChoice"] == {"any": {}} +def test_build_kwargs_keeps_tool_config_for_historical_tool_blocks_without_tools() -> None: + provider = BedrockProvider(region="us-east-1", client=FakeClient()) + messages = [ + {"role": "user", "content": "read x"}, + { + "role": "assistant", + "content": "", + "tool_calls": [{ + "id": "toolu_1", + "type": "function", + "function": {"name": "read_file", "arguments": '{"path": "x"}'}, + }], + }, + {"role": "tool", "tool_call_id": "toolu_1", "name": "read_file", "content": "ok"}, + {"role": "user", "content": "continue"}, + ] + + kwargs = provider._build_kwargs( + messages=messages, + tools=[], + model="bedrock/anthropic.claude-opus-4-7", + max_tokens=1024, + temperature=0.7, + reasoning_effort=None, + tool_choice=None, + ) + + assert any("toolUse" in block for msg in kwargs["messages"] for block in msg["content"]) + assert any("toolResult" in block for msg in kwargs["messages"] for block in msg["content"]) + assert kwargs["toolConfig"]["tools"][0]["toolSpec"]["name"] == "nanobot_noop" + assert "toolChoice" not in kwargs["toolConfig"] + + def test_parse_response_maps_text_tools_reasoning_usage_and_stop_reason() -> None: response = { "output": {