Compare commits

...

4 Commits

Author SHA1 Message Date
alex-clawd
8b6664ddb1 Normalize Snowflake Claude stringified tool calls 2026-06-02 05:21:25 -07:00
alex-clawd
c380fbd862 Handle Snowflake Claude toolUse content blocks 2026-06-02 05:17:30 -07:00
alex-clawd
13f5cd844b Filter Snowflake Claude preserved tool results 2026-06-02 05:08:02 -07:00
alex-clawd
60f432eb0e Fix Snowflake Claude incomplete tool result histories 2026-06-02 05:00:20 -07:00
2 changed files with 343 additions and 3 deletions

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import ast
import os
from typing import Any, Literal
@@ -133,6 +134,12 @@ 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
)
return self._ensure_claude_conversation_ends_with_user(formatted_messages)
return formatted_messages
@@ -140,6 +147,155 @@ 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],
) -> 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]
expected_ids = SnowflakeCompletion._extract_claude_tool_use_ids(message)
if message.get("role") != "assistant" or not expected_ids:
sanitized.append(message)
index += 1
continue
tool_result_ids: set[str] = set()
lookahead = index + 1
while lookahead < len(
messages
) and SnowflakeCompletion._is_tool_result_message(messages[lookahead]):
tool_result_ids.update(
SnowflakeCompletion._extract_claude_tool_result_ids(
messages[lookahead]
)
)
lookahead += 1
if expected_ids.issubset(tool_result_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 []
ids = {
tool_call.get("id")
for tool_call in tool_calls
if isinstance(tool_call, dict) and isinstance(tool_call.get("id"), str)
}
content = message.get("content")
if isinstance(content, list):
for block in content:
if isinstance(block, dict) and isinstance(block.get("toolUse"), dict):
tool_use_id = block["toolUse"].get("toolUseId")
if isinstance(tool_use_id, str):
ids.add(tool_use_id)
return ids
@staticmethod
def _extract_claude_tool_result_ids(message: LLMMessage) -> set[str]:
ids: set[str] = set()
tool_call_id = message.get("tool_call_id")
if isinstance(tool_call_id, str):
ids.add(tool_call_id)
content = message.get("content")
if isinstance(content, list):
for block in content:
if isinstance(block, dict) and isinstance(
block.get("toolResult"), dict
):
tool_use_id = block["toolResult"].get("toolUseId")
if isinstance(tool_use_id, str):
ids.add(tool_use_id)
return ids
@staticmethod
def _is_tool_result_message(message: LLMMessage) -> bool:
return message.get("role") == "tool" or bool(
SnowflakeCompletion._extract_claude_tool_result_ids(message)
)
@staticmethod
def _ensure_claude_conversation_ends_with_user(
messages: list[LLMMessage],

View File

@@ -156,7 +156,45 @@ 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_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
):
_snowflake_env(monkeypatch)
@@ -179,9 +217,155 @@ 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[-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
):
_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": "valid result",
},
{
"role": "tool",
"tool_call_id": "unrelated_call",
"content": "unrelated result",
},
]
)
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
):
_snowflake_env(monkeypatch)
llm = SnowflakeCompletion(model="claude-sonnet-4-5")
messages = llm._format_messages(
[
{"role": "user", "content": "Use the tool."},
{
"role": "assistant",
"content": [
{
"toolUse": {
"toolUseId": "tooluse_1",
"name": "lookup",
"input": {},
}
}
],
},
{"role": "user", "content": "Continue."},
]
)
assert messages == [
{"role": "user", "content": "Use the tool."},
{"role": "user", "content": "Continue."},
]
def test_claude_model_preserves_complete_tool_use_content_block_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": [
{
"toolUse": {
"toolUseId": "tooluse_1",
"name": "lookup",
"input": {},
}
}
],
},
{
"role": "user",
"content": [
{
"toolResult": {
"toolUseId": "tooluse_1",
"content": [{"text": "result"}],
}
}
],
},
]
)
assert messages[-2] == {"role": "user", "content": "Use the tool."}
assert messages[-1]["role"] == "user"
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