mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-06-22 00:28:14 +00:00
Compare commits
4 Commits
ci/python-
...
alex/fix-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b6664ddb1 | ||
|
|
c380fbd862 | ||
|
|
13f5cd844b | ||
|
|
60f432eb0e |
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user