diff --git a/lib/crewai/src/crewai/llms/providers/snowflake/completion.py b/lib/crewai/src/crewai/llms/providers/snowflake/completion.py index ed2e9c344..f6ecd5010 100644 --- a/lib/crewai/src/crewai/llms/providers/snowflake/completion.py +++ b/lib/crewai/src/crewai/llms/providers/snowflake/completion.py @@ -133,6 +133,9 @@ class SnowflakeCompletion(OpenAICompletion): def _format_messages(self, messages: str | list[LLMMessage]) -> list[LLMMessage]: formatted_messages = super()._format_messages(messages) if self._is_claude_model(): + formatted_messages = self._remove_incomplete_claude_tool_uses( + formatted_messages + ) return self._ensure_claude_conversation_ends_with_user(formatted_messages) return formatted_messages @@ -140,6 +143,59 @@ class SnowflakeCompletion(OpenAICompletion): model = self.model.lower() return model.startswith(("claude-", "anthropic.")) + @staticmethod + def _remove_incomplete_claude_tool_uses( + messages: list[LLMMessage], + ) -> list[LLMMessage]: + """Drop dangling Claude tool-use turns before sending to Snowflake. + + Snowflake-hosted Claude models reject histories where an assistant tool + use is not accompanied by matching tool results. CrewAI may retry or + summarize after an interrupted tool cycle, leaving an assistant + ``tool_calls`` message in history without every corresponding + ``role='tool'`` result. OpenAI-family models tolerate that more often, + but Claude through Snowflake returns: + "Each 'toolUse' block must be accompanied with a matching 'toolResult' block." + """ + sanitized: list[LLMMessage] = [] + index = 0 + + while index < len(messages): + message = messages[index] + tool_calls = message.get("tool_calls") or [] + if message.get("role") != "assistant" or not tool_calls: + sanitized.append(message) + index += 1 + continue + + expected_ids = { + tool_call.get("id") + for tool_call in tool_calls + if isinstance(tool_call, dict) and tool_call.get("id") + } + if not expected_ids: + sanitized.append(message) + index += 1 + continue + + tool_result_ids: set[str] = set() + lookahead = index + 1 + while ( + lookahead < len(messages) and messages[lookahead].get("role") == "tool" + ): + tool_call_id = messages[lookahead].get("tool_call_id") + if isinstance(tool_call_id, str): + tool_result_ids.add(tool_call_id) + lookahead += 1 + + if expected_ids.issubset(tool_result_ids): + sanitized.append(message) + sanitized.extend(messages[index + 1 : lookahead]) + + index = lookahead + + return sanitized + @staticmethod def _ensure_claude_conversation_ends_with_user( messages: list[LLMMessage], diff --git a/lib/crewai/tests/llms/snowflake/test_snowflake.py b/lib/crewai/tests/llms/snowflake/test_snowflake.py index 2cc45cdaa..56fc9347a 100644 --- a/lib/crewai/tests/llms/snowflake/test_snowflake.py +++ b/lib/crewai/tests/llms/snowflake/test_snowflake.py @@ -156,7 +156,7 @@ class TestSnowflakeRequests: assert messages == [{"role": "user", "content": "Write a summary."}] - def test_claude_model_adds_user_turn_after_tool_call_assistant_message( + def test_claude_model_removes_dangling_tool_call_without_result( self, monkeypatch: pytest.MonkeyPatch ): _snowflake_env(monkeypatch) @@ -179,8 +179,43 @@ class TestSnowflakeRequests: ] ) - assert messages[-2]["role"] == "assistant" - assert messages[-2]["tool_calls"][0]["id"] == "call_1" + assert messages == [{"role": "user", "content": "Use the tool."}] + + def test_claude_model_preserves_complete_tool_call_result_pair( + self, monkeypatch: pytest.MonkeyPatch + ): + _snowflake_env(monkeypatch) + llm = SnowflakeCompletion(model="claude-sonnet-4-5") + + messages = llm._format_messages( + [ + {"role": "user", "content": "Use the tool."}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "lookup", "arguments": "{}"}, + } + ], + }, + { + "role": "tool", + "tool_call_id": "call_1", + "content": "result", + }, + ] + ) + + assert messages[-3]["role"] == "assistant" + assert messages[-3]["tool_calls"][0]["id"] == "call_1" + assert messages[-2] == { + "role": "tool", + "tool_call_id": "call_1", + "content": "result", + } assert messages[-1]["role"] == "user" def test_claude_model_maps_max_tokens_to_max_completion_tokens(