From 8b6664ddb1e551af1101ceb5a8cba561e469480d Mon Sep 17 00:00:00 2001 From: alex-clawd Date: Tue, 2 Jun 2026 05:21:25 -0700 Subject: [PATCH] Normalize Snowflake Claude stringified tool calls --- .../llms/providers/snowflake/completion.py | 73 +++++++++++++-- .../tests/llms/snowflake/test_snowflake.py | 89 +++++++++++-------- 2 files changed, 121 insertions(+), 41 deletions(-) diff --git a/lib/crewai/src/crewai/llms/providers/snowflake/completion.py b/lib/crewai/src/crewai/llms/providers/snowflake/completion.py index bc5d77912..d3ae46a27 100644 --- a/lib/crewai/src/crewai/llms/providers/snowflake/completion.py +++ b/lib/crewai/src/crewai/llms/providers/snowflake/completion.py @@ -1,5 +1,6 @@ from __future__ import annotations +import ast import os from typing import Any, Literal @@ -133,6 +134,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._normalize_stringified_tool_calls( + formatted_messages + ) formatted_messages = self._remove_incomplete_claude_tool_uses( formatted_messages ) @@ -143,6 +147,41 @@ class SnowflakeCompletion(OpenAICompletion): model = self.model.lower() return model.startswith(("claude-", "anthropic.")) + @staticmethod + def _normalize_stringified_tool_calls( + messages: list[LLMMessage], + ) -> list[LLMMessage]: + normalized_messages: list[LLMMessage] = [] + for message in messages: + tool_calls = message.get("tool_calls") + if not isinstance(tool_calls, list) or not tool_calls: + normalized_messages.append(message) + continue + + normalized_tool_calls: list[Any] = [] + changed = False + for tool_call in tool_calls: + if isinstance(tool_call, str): + try: + parsed_tool_call = ast.literal_eval(tool_call) + except (ValueError, SyntaxError): + normalized_tool_calls.append(tool_call) + continue + if isinstance(parsed_tool_call, dict): + normalized_tool_calls.append(parsed_tool_call) + changed = True + continue + normalized_tool_calls.append(tool_call) + + if changed: + normalized_message = dict(message) + normalized_message["tool_calls"] = normalized_tool_calls + normalized_messages.append(normalized_message) # type: ignore[arg-type] + else: + normalized_messages.append(message) + + return normalized_messages + @staticmethod def _remove_incomplete_claude_tool_uses( messages: list[LLMMessage], @@ -181,18 +220,40 @@ class SnowflakeCompletion(OpenAICompletion): lookahead += 1 if expected_ids.issubset(tool_result_ids): - sanitized.append(message) - sanitized.extend( - tool_message - for tool_message in messages[index + 1 : lookahead] - if SnowflakeCompletion._extract_claude_tool_result_ids(tool_message) - & expected_ids + summary = SnowflakeCompletion._summarize_tool_results( + messages[index + 1 : lookahead], expected_ids ) + if summary: + sanitized.append({"role": "user", "content": summary}) index = lookahead return sanitized + @staticmethod + def _summarize_tool_results( + messages: list[LLMMessage], expected_ids: set[str] + ) -> str: + summaries: list[str] = [] + for message in messages: + result_ids = SnowflakeCompletion._extract_claude_tool_result_ids(message) + if not result_ids & expected_ids: + continue + + name = message.get("name") or "tool" + content = message.get("content") + if isinstance(content, str): + summaries.append(f"{name}: {content}") + elif isinstance(content, list): + summaries.append(f"{name}: {content}") + + if not summaries: + return "" + + return "Tool results from previous tool calls:\n" + "\n".join( + f"- {summary}" for summary in summaries + ) + @staticmethod def _extract_claude_tool_use_ids(message: LLMMessage) -> set[str]: tool_calls = message.get("tool_calls") or [] diff --git a/lib/crewai/tests/llms/snowflake/test_snowflake.py b/lib/crewai/tests/llms/snowflake/test_snowflake.py index 032574c17..fce9c9914 100644 --- a/lib/crewai/tests/llms/snowflake/test_snowflake.py +++ b/lib/crewai/tests/llms/snowflake/test_snowflake.py @@ -156,6 +156,44 @@ class TestSnowflakeRequests: assert messages == [{"role": "user", "content": "Write a summary."}] + def test_claude_model_normalizes_stringified_tool_calls_with_results( + self, monkeypatch: pytest.MonkeyPatch + ): + _snowflake_env(monkeypatch) + llm = SnowflakeCompletion(model="claude-sonnet-4-5") + + messages = llm._format_messages( + [ + {"role": "user", "content": "Use the tools."}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + "{'id': 'toolu_1', 'type': 'function', 'function': {'name': \"'search_the_internet_with_serper'\", 'arguments': '\\\'{\"search_query\":\"CrewAI tools\"}\\\''}}", + "{'id': 'toolu_2', 'type': 'function', 'function': {'name': \"'search_the_internet_with_serper'\", 'arguments': '\\\'{\"search_query\":\"CrewAI demos\"}\\\''}}", + ], + }, + { + "role": "tool", + "tool_call_id": "toolu_1", + "name": "search_the_internet_with_serper", + "content": "result 1", + }, + { + "role": "tool", + "tool_call_id": "toolu_2", + "name": "search_the_internet_with_serper", + "content": "result 2", + }, + ] + ) + + assert messages[-2] == {"role": "user", "content": "Use the tools."} + assert messages[-1]["role"] == "user" + assert "result 1" in messages[-1]["content"] + assert "result 2" in messages[-1]["content"] + assert all("tool_calls" not in message for message in messages) + def test_claude_model_removes_dangling_tool_call_without_result( self, monkeypatch: pytest.MonkeyPatch ): @@ -209,14 +247,10 @@ class TestSnowflakeRequests: ] ) - 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[-2] == {"role": "user", "content": "Use the tool."} assert messages[-1]["role"] == "user" + assert "result" in messages[-1]["content"] + assert all("tool_calls" not in message for message in messages) def test_claude_model_drops_unrelated_tool_results_from_preserved_pair( self, monkeypatch: pytest.MonkeyPatch @@ -251,16 +285,11 @@ class TestSnowflakeRequests: ] ) - assert messages[-3]["role"] == "assistant" - assert messages[-2] == { - "role": "tool", - "tool_call_id": "call_1", - "content": "valid result", - } - assert all( - message.get("tool_call_id") != "unrelated_call" for message in messages - ) + assert messages[-2] == {"role": "user", "content": "Use the tool."} assert messages[-1]["role"] == "user" + assert "valid result" in messages[-1]["content"] + assert "unrelated result" not in messages[-1]["content"] + assert all("tool_call_id" not in message for message in messages) def test_claude_model_removes_dangling_tool_use_content_block( self, monkeypatch: pytest.MonkeyPatch @@ -327,26 +356,16 @@ class TestSnowflakeRequests: ] ) - assert messages[-3] == {"role": "user", "content": "Use the tool."} - assert messages[-2]["role"] == "assistant" - assert messages[-2]["content"] == [ - { - "toolUse": { - "toolUseId": "tooluse_1", - "name": "lookup", - "input": {}, - } - } - ] + assert messages[-2] == {"role": "user", "content": "Use the tool."} assert messages[-1]["role"] == "user" - assert messages[-1]["content"] == [ - { - "toolResult": { - "toolUseId": "tooluse_1", - "content": [{"text": "result"}], - } - } - ] + assert "toolResult" in messages[-1]["content"] + assert all( + not ( + message.get("role") == "assistant" + and isinstance(message.get("content"), list) + ) + for message in messages + ) def test_claude_model_maps_max_tokens_to_max_completion_tokens( self, monkeypatch: pytest.MonkeyPatch