Compare commits

..

3 Commits

Author SHA1 Message Date
Greyson LaLonde
cf51bf0d9f Merge branch 'main' into lg-mcp-event-loop 2026-03-02 12:05:06 -05:00
Lucas Gomide
a96f114a36 Merge branch 'main' into lg-mcp-event-loop 2026-02-27 12:08:38 -03:00
Lucas Gomide
ca220cdc23 fix: use persistent event loop for MCP operations to prevent cancel scope errors
Replace per-call asyncio.run() with a single persistent background event
loop for all MCP operations. The MCP SDK's streamable HTTP transport uses
anyio task groups whose cancel scopes must be entered and exited on the
same event loop and task. Creating a throwaway loop per tool call caused
"Attempted to exit cancel scope in a different task" RuntimeErrors during
cleanup, preventing MCP tools from working reliably
2026-02-27 11:37:50 -03:00
13 changed files with 157 additions and 1033 deletions

View File

@@ -21,7 +21,7 @@ dependencies = [
"opentelemetry-exporter-otlp-proto-http~=1.34.0",
# Data Handling
"chromadb~=1.1.0",
"tokenizers>=0.21,<1",
"tokenizers~=0.20.3",
"openpyxl~=3.1.5",
# Authentication and Security
"python-dotenv~=1.1.1",
@@ -88,7 +88,7 @@ bedrock = [
"boto3~=1.40.45",
]
google-genai = [
"google-genai~=1.65.0",
"google-genai~=1.49.0",
]
azure-ai-inference = [
"azure-ai-inference~=1.0.0b9",

View File

@@ -4,7 +4,6 @@ import urllib.request
import warnings
from crewai.agent.core import Agent
from crewai.agents.loop_detector import LoopDetector
from crewai.crew import Crew
from crewai.crews.crew_output import CrewOutput
from crewai.flow.flow import Flow
@@ -100,7 +99,6 @@ __all__ = [
"Flow",
"Knowledge",
"LLMGuardrail",
"LoopDetector",
"Memory",
"Process",
"Task",

View File

@@ -22,7 +22,6 @@ from typing_extensions import Self
from crewai.agent.internal.meta import AgentMeta
from crewai.agents.agent_builder.utilities.base_token_process import TokenProcess
from crewai.agents.cache.cache_handler import CacheHandler
from crewai.agents.loop_detector import LoopDetector
from crewai.agents.tools_handler import ToolsHandler
from crewai.knowledge.knowledge import Knowledge
from crewai.knowledge.knowledge_config import KnowledgeConfig
@@ -214,15 +213,6 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
"If not set, falls back to crew memory."
),
)
loop_detector: LoopDetector | None = Field(
default=None,
description=(
"Optional loop detection middleware. When set, monitors agent tool calls "
"for repetitive patterns and intervenes to break loops. "
"Pass a LoopDetector instance to configure window_size, "
"repetition_threshold, and on_loop behavior."
),
)
@model_validator(mode="before")
@classmethod

View File

@@ -17,7 +17,6 @@ from pydantic import BaseModel, GetCoreSchemaHandler, ValidationError
from pydantic_core import CoreSchema, core_schema
from crewai.agents.agent_builder.base_agent_executor_mixin import CrewAgentExecutorMixin
from crewai.agents.loop_detector import LoopDetector
from crewai.agents.parser import (
AgentAction,
AgentFinish,
@@ -157,11 +156,6 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
self.messages: list[LLMMessage] = []
self.iterations = 0
self.log_error_after = 3
self.loop_detector: LoopDetector | None = (
agent.loop_detector if agent and hasattr(agent, "loop_detector") else None
)
if self.loop_detector:
self.loop_detector.reset()
self.before_llm_call_hooks: list[Callable[..., Any]] = []
self.after_llm_call_hooks: list[Callable[..., Any]] = []
self.before_llm_call_hooks.extend(get_before_llm_call_hooks())
@@ -416,11 +410,6 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
)
}
# Capture tool info before _handle_agent_action may
# convert the answer to AgentFinish (result_as_answer).
_tool_name = formatted_answer.tool
_tool_input = formatted_answer.tool_input
tool_result = execute_tool_and_check_finality(
agent_action=formatted_answer,
fingerprint_context=fingerprint_context,
@@ -438,14 +427,6 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
formatted_answer, tool_result
)
# Check for repetitive loop patterns
loop_result = self._record_and_check_loop(
_tool_name, _tool_input
)
if loop_result is not None:
formatted_answer = loop_result
break
self._invoke_step_callback(formatted_answer) # type: ignore[arg-type]
self._append_message(formatted_answer.text) # type: ignore[union-attr]
@@ -802,14 +783,6 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
if tool_finish:
return tool_finish
# Check for loop after each parallel tool result
loop_result = self._record_and_check_loop(
execution_result["func_name"],
execution_result.get("tool_args", ""),
)
if loop_result is not None:
return loop_result
reasoning_prompt = self._i18n.slice("post_tool_reasoning")
reasoning_message: LLMMessage = {
"role": "user",
@@ -834,11 +807,6 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
if tool_finish:
return tool_finish
# Check for loop after sequential tool execution
loop_result = self._record_and_check_loop(func_name, func_args)
if loop_result is not None:
return loop_result
reasoning_prompt = self._i18n.slice("post_tool_reasoning")
reasoning_message = {
"role": "user",
@@ -1100,7 +1068,6 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
"result": result,
"from_cache": from_cache,
"original_tool": original_tool,
"tool_args": func_args,
}
def _append_tool_result_and_check_finality(
@@ -1279,11 +1246,6 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
)
}
# Capture tool info before _handle_agent_action may
# convert the answer to AgentFinish (result_as_answer).
_tool_name = formatted_answer.tool
_tool_input = formatted_answer.tool_input
tool_result = await aexecute_tool_and_check_finality(
agent_action=formatted_answer,
fingerprint_context=fingerprint_context,
@@ -1301,14 +1263,6 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
formatted_answer, tool_result
)
# Check for repetitive loop patterns
loop_result = self._record_and_check_loop(
_tool_name, _tool_input
)
if loop_result is not None:
formatted_answer = loop_result
break
await self._ainvoke_step_callback(formatted_answer) # type: ignore[arg-type]
self._append_message(formatted_answer.text) # type: ignore[union-attr]
@@ -1508,94 +1462,6 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
self._show_logs(formatted_answer)
return formatted_answer
def _record_and_check_loop(
self, tool_name: str, tool_args: str | dict[str, Any]
) -> AgentFinish | None:
"""Record a tool call and check for repetitive loop patterns.
If a loop is detected, takes the configured action:
- ``inject_reflection``: injects a reflection prompt into messages.
- ``stop``: forces the agent to produce a final answer.
- callable: calls the user's callback and injects the returned message.
Args:
tool_name: Name of the tool that was called.
tool_args: Arguments passed to the tool.
Returns:
``AgentFinish`` if the loop action is ``stop``,
``None`` otherwise (including when reflection is injected).
"""
if not self.loop_detector:
return None
self.loop_detector.record_tool_call(tool_name, tool_args)
if not self.loop_detector.is_loop_detected():
return None
repeated_tool = self.loop_detector.get_repeated_tool_info() or tool_name
action_taken = (
self.loop_detector.on_loop
if isinstance(self.loop_detector.on_loop, str)
else "callback"
)
# Emit loop detected event
from crewai.events.types.loop_events import LoopDetectedEvent
crewai_event_bus.emit(
self.agent,
LoopDetectedEvent(
agent_role=self.agent.role if self.agent else "unknown",
agent_id=str(self.agent.id) if self.agent else None,
task_id=str(self.task.id) if self.task else None,
repeated_tool=repeated_tool,
action_taken=action_taken,
iteration=self.iterations,
agent=self.agent,
),
)
if self.agent and self.agent.verbose:
self._printer.print(
content=f"Loop detected: {repeated_tool} called repeatedly. Action: {action_taken}",
color="bold_yellow",
)
if self.loop_detector.on_loop == "stop":
return handle_max_iterations_exceeded(
None,
printer=self._printer,
i18n=self._i18n,
messages=self.messages,
llm=self.llm,
callbacks=self.callbacks,
verbose=self.agent.verbose if self.agent else False,
)
# inject_reflection or callable
if callable(self.loop_detector.on_loop) and not isinstance(
self.loop_detector.on_loop, str
):
message_text = self.loop_detector.get_loop_message()
else:
# Use i18n reflection prompt
count = self.loop_detector.repetition_threshold
message_text = self._i18n.slice("loop_detected").format(
repeated_tool=repeated_tool, count=count
)
loop_message: LLMMessage = {
"role": "user",
"content": message_text,
}
self.messages.append(loop_message)
# Reset detector after intervention to give the agent a fresh window
self.loop_detector.reset()
return None
def _handle_agent_action(
self, formatted_answer: AgentAction, tool_result: ToolResult
) -> AgentAction | AgentFinish:

View File

@@ -1,210 +0,0 @@
"""Loop detection for agent execution.
Detects repetitive behavioral patterns in agent tool calls and provides
configurable intervention strategies to break out of loops.
Example usage:
from crewai import Agent
from crewai.agents.loop_detector import LoopDetector
# Using default settings (window_size=5, repetition_threshold=3, inject_reflection)
agent = Agent(
role="Researcher",
goal="Find novel insights",
backstory="An experienced researcher",
loop_detector=LoopDetector(),
)
# Custom configuration
agent = Agent(
role="Researcher",
goal="Find novel insights",
backstory="An experienced researcher",
loop_detector=LoopDetector(
window_size=10,
repetition_threshold=4,
on_loop="stop",
),
)
# With a custom callback
def my_callback(detector: LoopDetector) -> str:
return "You are repeating yourself. Try a completely different approach."
agent = Agent(
role="Researcher",
goal="Find novel insights",
backstory="An experienced researcher",
loop_detector=LoopDetector(
on_loop=my_callback,
),
)
"""
from __future__ import annotations
from collections import deque
from collections.abc import Callable
import json
import logging
from typing import Any, Literal
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
class ToolCallRecord(BaseModel):
"""Record of a single tool call for loop detection."""
tool_name: str
tool_args: str # Normalized JSON string of arguments
def __eq__(self, other: object) -> bool:
if not isinstance(other, ToolCallRecord):
return NotImplemented
return self.tool_name == other.tool_name and self.tool_args == other.tool_args
def __hash__(self) -> int:
return hash((self.tool_name, self.tool_args))
class LoopDetector(BaseModel):
"""Detects repetitive behavioral patterns in agent tool calls.
Monitors a sliding window of recent tool calls and flags when
the same tool is called repeatedly with the same arguments,
indicating the agent is stuck in a loop.
Args:
window_size: Number of recent tool calls to track in the
sliding window. Defaults to 5.
repetition_threshold: How many identical tool calls within
the window trigger loop detection. Defaults to 3.
on_loop: Action to take when a loop is detected.
- ``"inject_reflection"`` (default): Inject a meta-prompt
asking the agent to try a different approach.
- ``"stop"``: Force the agent to produce a final answer.
- A callable ``(LoopDetector) -> str``: Custom callback
that returns a message to inject into the conversation.
Example::
from crewai import Agent
from crewai.agents.loop_detector import LoopDetector
agent = Agent(
role="Researcher",
goal="Find novel insights about AI",
backstory="Senior researcher",
loop_detector=LoopDetector(
window_size=5,
repetition_threshold=3,
on_loop="inject_reflection",
),
)
"""
window_size: int = Field(
default=5,
ge=2,
description="Number of recent tool calls to track in the sliding window.",
)
repetition_threshold: int = Field(
default=3,
ge=2,
description="How many identical tool calls within the window trigger loop detection.",
)
on_loop: Literal["inject_reflection", "stop"] | Callable[..., str] = Field(
default="inject_reflection",
description=(
"Action when loop is detected: 'inject_reflection' to inject a reflection prompt, "
"'stop' to force final answer, or a callable(LoopDetector) -> str for custom handling."
),
)
_history: deque[ToolCallRecord] = deque()
def model_post_init(self, __context: Any) -> None:
"""Initialize the internal deque with the configured window size."""
object.__setattr__(self, "_history", deque(maxlen=self.window_size))
@staticmethod
def _normalize_args(args: str | dict[str, Any]) -> str:
"""Normalize tool arguments to a canonical JSON string for comparison.
Args:
args: Tool arguments as a string or dict.
Returns:
A normalized JSON string with sorted keys.
"""
if isinstance(args, str):
try:
parsed = json.loads(args)
except (json.JSONDecodeError, TypeError):
return args.strip()
return json.dumps(parsed, sort_keys=True, default=str)
return json.dumps(args, sort_keys=True, default=str)
def record_tool_call(self, tool_name: str, tool_args: str | dict[str, Any]) -> None:
"""Record a tool call in the sliding window.
Args:
tool_name: Name of the tool that was called.
tool_args: Arguments passed to the tool.
"""
record = ToolCallRecord(
tool_name=tool_name,
tool_args=self._normalize_args(tool_args),
)
self._history.append(record)
def is_loop_detected(self) -> bool:
"""Check if a repetitive loop pattern is detected.
Returns:
True if any single tool call (same name + same args)
appears at least ``repetition_threshold`` times within
the current window.
"""
if len(self._history) < self.repetition_threshold:
return False
counts: dict[ToolCallRecord, int] = {}
for record in self._history:
counts[record] = counts.get(record, 0) + 1
if counts[record] >= self.repetition_threshold:
return True
return False
def get_loop_message(self) -> str:
"""Get the intervention message based on the configured ``on_loop`` action.
Returns:
A string message to inject into the agent conversation.
"""
if callable(self.on_loop) and not isinstance(self.on_loop, str):
return self.on_loop(self)
return ""
def get_repeated_tool_info(self) -> str | None:
"""Get information about the repeated tool call, if any.
Returns:
A string describing the repeated tool and args, or None.
"""
if len(self._history) < self.repetition_threshold:
return None
counts: dict[ToolCallRecord, int] = {}
for record in self._history:
counts[record] = counts.get(record, 0) + 1
if counts[record] >= self.repetition_threshold:
return f"{record.tool_name}({record.tool_args})"
return None
def reset(self) -> None:
"""Clear the tool call history."""
self._history.clear()

View File

@@ -1,48 +0,0 @@
"""Events related to agent loop detection."""
from __future__ import annotations
from typing import Any
from pydantic import ConfigDict, model_validator
from crewai.events.base_events import BaseEvent
class LoopDetectedEvent(BaseEvent):
"""Event emitted when a repetitive loop pattern is detected in agent behavior.
Attributes:
agent_role: Role of the agent that is looping.
repeated_tool: Description of the repeated tool call (name + args).
action_taken: The action taken to break the loop.
iteration: Current iteration count when the loop was detected.
"""
agent_role: str
agent_id: str | None = None
task_id: str | None = None
repeated_tool: str | None = None
action_taken: str
iteration: int
agent: Any | None = None
type: str = "loop_detected"
model_config = ConfigDict(arbitrary_types_allowed=True)
@model_validator(mode="after")
def set_fingerprint_data(self) -> LoopDetectedEvent:
"""Set fingerprint data from the agent if available."""
if (
self.agent
and hasattr(self.agent, "fingerprint")
and self.agent.fingerprint
):
self.source_fingerprint = self.agent.fingerprint.uuid_str
self.source_type = "agent"
if (
hasattr(self.agent.fingerprint, "metadata")
and self.agent.fingerprint.metadata
):
self.fingerprint_metadata = self.agent.fingerprint.metadata
return self

View File

@@ -95,7 +95,7 @@ class MCPClient:
self.discovery_timeout = discovery_timeout
self.max_retries = max_retries
self.cache_tools_list = cache_tools_list
# self._logger = logger or logging.getLogger(__name__)
self._logger = logger or logging.getLogger(__name__)
self._session: Any = None
self._initialized = False
self._exit_stack = AsyncExitStack()
@@ -358,10 +358,12 @@ class MCPClient:
"""Cleanup resources when an error occurs during connection."""
try:
await self._exit_stack.aclose()
except Exception as e:
# Best effort cleanup - ignore all other errors
raise RuntimeError(f"Error during MCP client cleanup: {e}") from e
except (RuntimeError, BaseExceptionGroup) as e:
error_msg = str(e).lower()
if "cancel scope" not in error_msg and "task" not in error_msg:
raise RuntimeError(f"Error during MCP client cleanup: {e}") from e
except Exception:
self._logger.debug("Suppressed error during MCP cleanup", exc_info=True)
finally:
self._session = None
self._initialized = False
@@ -374,8 +376,12 @@ class MCPClient:
try:
await self._exit_stack.aclose()
except Exception as e:
raise RuntimeError(f"Error during MCP client disconnect: {e}") from e
except (RuntimeError, BaseExceptionGroup) as e:
error_msg = str(e).lower()
if "cancel scope" not in error_msg and "task" not in error_msg:
raise RuntimeError(f"Error during MCP client disconnect: {e}") from e
except Exception:
self._logger.debug("Suppressed error during MCP disconnect", exc_info=True)
finally:
self._session = None
self._initialized = False

View File

@@ -87,7 +87,12 @@ class MCPToolResolver:
return all_tools
def cleanup(self) -> None:
"""Disconnect all MCP client connections."""
"""Disconnect all MCP client connections.
Submits the disconnect coroutines to the persistent MCP event loop
so that transport context managers are exited on the same loop they
were entered on.
"""
if not self._clients:
return
@@ -97,7 +102,11 @@ class MCPToolResolver:
await client.disconnect()
try:
asyncio.run(_disconnect_all())
from crewai.tools.mcp_native_tool import _get_mcp_event_loop
loop = _get_mcp_event_loop()
future = asyncio.run_coroutine_threadsafe(_disconnect_all(), loop)
future.result(timeout=30)
except Exception as e:
self._logger.log("error", f"Error during MCP client cleanup: {e}")
finally:
@@ -330,30 +339,27 @@ class MCPToolResolver:
) from e
try:
try:
asyncio.get_running_loop()
import concurrent.futures
from crewai.tools.mcp_native_tool import _get_mcp_event_loop
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(
asyncio.run, _setup_client_and_list_tools()
)
tools_list = future.result()
except RuntimeError:
try:
tools_list = asyncio.run(_setup_client_and_list_tools())
except RuntimeError as e:
error_msg = str(e).lower()
if "cancel scope" in error_msg or "task" in error_msg:
raise ConnectionError(
"MCP connection failed due to event loop cleanup issues. "
"This may be due to authentication errors or server unavailability."
) from e
except asyncio.CancelledError as e:
loop = _get_mcp_event_loop()
future = asyncio.run_coroutine_threadsafe(
_setup_client_and_list_tools(), loop
)
try:
tools_list = future.result(timeout=60)
except RuntimeError as e:
error_msg = str(e).lower()
if "cancel scope" in error_msg or "task" in error_msg:
raise ConnectionError(
"MCP connection was cancelled. This may indicate an authentication "
"error or server unavailability."
"MCP connection failed due to event loop cleanup issues. "
"This may be due to authentication errors or server unavailability."
) from e
raise
except asyncio.CancelledError as e:
raise ConnectionError(
"MCP connection was cancelled. This may indicate an authentication "
"error or server unavailability."
) from e
if mcp_config.tool_filter:
filtered_tools = []
@@ -410,7 +416,13 @@ class MCPToolResolver:
return cast(list[BaseTool], tools), client
except Exception as e:
if client.connected:
asyncio.run(client.disconnect())
try:
fut = asyncio.run_coroutine_threadsafe(
client.disconnect(), loop
)
fut.result(timeout=10)
except Exception:
self._logger.log("debug", "Suppressed error during MCP client disconnect on cleanup")
raise RuntimeError(f"Failed to get native MCP tools: {e}") from e

View File

@@ -5,11 +5,37 @@ for better performance and connection management.
"""
import asyncio
import threading
from typing import Any
from crewai.tools import BaseTool
_mcp_loop: asyncio.AbstractEventLoop | None = None
_mcp_loop_thread: threading.Thread | None = None
_mcp_loop_lock = threading.Lock()
def _get_mcp_event_loop() -> asyncio.AbstractEventLoop:
"""Return (and lazily start) a persistent event loop for MCP operations.
All MCP SDK transports use anyio task groups whose cancel scopes must be
entered and exited on the same event loop / task. By funnelling every
MCP call through one long-lived loop we avoid the "exit cancel scope in
a different task" errors that happen when asyncio.run() creates a
throwaway loop per call.
"""
global _mcp_loop, _mcp_loop_thread
with _mcp_loop_lock:
if _mcp_loop is None or _mcp_loop.is_closed():
_mcp_loop = asyncio.new_event_loop()
_mcp_loop_thread = threading.Thread(
target=_mcp_loop.run_forever, daemon=True, name="mcp-event-loop"
)
_mcp_loop_thread.start()
return _mcp_loop
class MCPNativeTool(BaseTool):
"""Native MCP tool that reuses client sessions.
@@ -38,13 +64,10 @@ class MCPNativeTool(BaseTool):
server_name: Name of the MCP server for prefixing.
original_tool_name: Original name of the tool on the MCP server.
"""
# Create tool name with server prefix to avoid conflicts
prefixed_name = f"{server_name}_{tool_name}"
# Handle args_schema properly - BaseTool expects a BaseModel subclass
args_schema = tool_schema.get("args_schema")
# Only pass args_schema if it's provided
kwargs = {
"name": prefixed_name,
"description": tool_schema.get(
@@ -57,11 +80,9 @@ class MCPNativeTool(BaseTool):
super().__init__(**kwargs)
# Set instance attributes after super().__init__
self._mcp_client = mcp_client
self._original_tool_name = original_tool_name or tool_name
self._server_name = server_name
# self._logger = logging.getLogger(__name__)
@property
def mcp_client(self) -> Any:
@@ -81,25 +102,21 @@ class MCPNativeTool(BaseTool):
def _run(self, **kwargs) -> str:
"""Execute tool using the MCP client session.
Submits work to a persistent background event loop so that all MCP
transport context managers (which rely on anyio cancel scopes) stay
on the same loop and task throughout their lifecycle.
Args:
**kwargs: Arguments to pass to the MCP tool.
Returns:
Result from the MCP tool execution.
"""
loop = _get_mcp_event_loop()
timeout = self._mcp_client.connect_timeout + self._mcp_client.execution_timeout
try:
try:
asyncio.get_running_loop()
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as executor:
coro = self._run_async(**kwargs)
future = executor.submit(asyncio.run, coro)
return future.result()
except RuntimeError:
return asyncio.run(self._run_async(**kwargs))
future = asyncio.run_coroutine_threadsafe(self._run_async(**kwargs), loop)
return future.result(timeout=timeout)
except Exception as e:
raise RuntimeError(
f"Error executing MCP tool {self.original_tool_name}: {e!s}"
@@ -114,18 +131,11 @@ class MCPNativeTool(BaseTool):
Returns:
Result from the MCP tool execution.
"""
# Note: Since we use asyncio.run() which creates a new event loop each time,
# Always reconnect on-demand because asyncio.run() creates new event loops per call
# All MCP transport context managers (stdio, streamablehttp_client, sse_client)
# use anyio.create_task_group() which can't span different event loops
if self._mcp_client.connected:
await self._mcp_client.disconnect()
await self._mcp_client.connect()
if not self._mcp_client.connected:
await self._mcp_client.connect()
try:
result = await self._mcp_client.call_tool(self.original_tool_name, kwargs)
except Exception as e:
error_str = str(e).lower()
if (
@@ -135,24 +145,15 @@ class MCPNativeTool(BaseTool):
):
await self._mcp_client.disconnect()
await self._mcp_client.connect()
# Retry the call
result = await self._mcp_client.call_tool(
self.original_tool_name, kwargs
)
else:
raise
finally:
# Always disconnect after tool call to ensure clean context manager lifecycle
# This prevents "exit cancel scope in different task" errors
# All transport context managers must be exited in the same event loop they were entered
await self._mcp_client.disconnect()
# Extract result content
if isinstance(result, str):
return result
# Handle various result formats
if hasattr(result, "content") and result.content:
if isinstance(result.content, list) and len(result.content) > 0:
content_item = result.content[0]

View File

@@ -15,7 +15,6 @@
"native_tools": "",
"native_task": "\nCurrent Task: {input}",
"post_tool_reasoning": "Analyze the tool result. If requirements are met, provide the Final Answer. Otherwise, call the next tool. Deliver only the answer without meta-commentary.",
"loop_detected": "WARNING: You appear to be repeating the same action ({repeated_tool}). You have called it {count} times with the same arguments. This is not making progress. You MUST try a completely different approach — use a different tool, different arguments, or provide your Final Answer now.",
"format": "Decide if you need a tool or can provide the final answer. Use one at a time.\nTo use a tool, use:\nThought: [reasoning]\nAction: [name from {tool_names}]\nAction Input: [JSON object]\n\nTo provide the final answer, use:\nThought: [reasoning]\nFinal Answer: [complete response]",
"final_answer_format": "If you don't need to use any more tools, you must give your best complete final answer, make sure it satisfies the expected criteria, use the EXACT format below:\n\n```\nThought: I now can give a great answer\nFinal Answer: my best complete final answer to the task.\n\n```",
"format_without_tools": "\nSorry, I didn't use the right format. I MUST either use a tool (among the available ones), OR give my best final answer.\nHere is the expected format I must follow:\n\n```\nQuestion: the input question you must answer\nThought: you should always think about what to do\nAction: the action to take, should be one of [{tool_names}]\nAction Input: the input to the action\nObservation: the result of the action\n```\n This Thought/Action/Action Input/Result process can repeat N times. Once I know the final answer, I must return the following format:\n\n```\nThought: I now can give a great answer\nFinal Answer: Your final answer must be the great and the most complete as possible, it must be outcome described\n\n```",

View File

@@ -1,523 +0,0 @@
"""Unit tests for LoopDetector and loop detection integration.
Tests cover:
- LoopDetector class: tracking, detection, message generation, configuration
- ToolCallRecord equality and hashing
- Edge cases: empty history, threshold boundaries, window overflow
- Integration with CrewAgentExecutor._record_and_check_loop
- LoopDetectedEvent creation
"""
from __future__ import annotations
from unittest.mock import MagicMock, Mock, patch
import pytest
from crewai.agents.loop_detector import LoopDetector, ToolCallRecord
from crewai.events.types.loop_events import LoopDetectedEvent
# ---------------------------------------------------------------------------
# ToolCallRecord
# ---------------------------------------------------------------------------
class TestToolCallRecord:
"""Tests for ToolCallRecord model."""
def test_equality_same_name_and_args(self):
a = ToolCallRecord(tool_name="search", tool_args='{"q": "hello"}')
b = ToolCallRecord(tool_name="search", tool_args='{"q": "hello"}')
assert a == b
def test_inequality_different_name(self):
a = ToolCallRecord(tool_name="search", tool_args='{"q": "hello"}')
b = ToolCallRecord(tool_name="fetch", tool_args='{"q": "hello"}')
assert a != b
def test_inequality_different_args(self):
a = ToolCallRecord(tool_name="search", tool_args='{"q": "hello"}')
b = ToolCallRecord(tool_name="search", tool_args='{"q": "world"}')
assert a != b
def test_hash_same_records(self):
a = ToolCallRecord(tool_name="search", tool_args='{"q": "hello"}')
b = ToolCallRecord(tool_name="search", tool_args='{"q": "hello"}')
assert hash(a) == hash(b)
def test_hash_different_records(self):
a = ToolCallRecord(tool_name="search", tool_args='{"q": "hello"}')
b = ToolCallRecord(tool_name="search", tool_args='{"q": "world"}')
# Different records should (almost certainly) have different hashes
assert hash(a) != hash(b)
def test_equality_not_implemented_for_other_types(self):
a = ToolCallRecord(tool_name="search", tool_args="{}")
assert a != "not a record"
assert a.__eq__("not a record") is NotImplemented
# ---------------------------------------------------------------------------
# LoopDetector — Initialization
# ---------------------------------------------------------------------------
class TestLoopDetectorInit:
"""Tests for LoopDetector initialization and defaults."""
def test_default_values(self):
ld = LoopDetector()
assert ld.window_size == 5
assert ld.repetition_threshold == 3
assert ld.on_loop == "inject_reflection"
def test_custom_values(self):
ld = LoopDetector(window_size=10, repetition_threshold=4, on_loop="stop")
assert ld.window_size == 10
assert ld.repetition_threshold == 4
assert ld.on_loop == "stop"
def test_callable_on_loop(self):
def my_cb(detector: LoopDetector) -> str:
return "custom"
ld = LoopDetector(on_loop=my_cb)
assert callable(ld.on_loop)
def test_window_size_minimum(self):
with pytest.raises(Exception):
LoopDetector(window_size=1)
def test_repetition_threshold_minimum(self):
with pytest.raises(Exception):
LoopDetector(repetition_threshold=1)
# ---------------------------------------------------------------------------
# LoopDetector — Argument Normalization
# ---------------------------------------------------------------------------
class TestNormalizeArgs:
"""Tests for LoopDetector._normalize_args static method."""
def test_dict_sorted_keys(self):
result = LoopDetector._normalize_args({"b": 2, "a": 1})
assert result == '{"a": 1, "b": 2}'
def test_json_string_normalized(self):
result = LoopDetector._normalize_args('{"b": 2, "a": 1}')
assert result == '{"a": 1, "b": 2}'
def test_plain_string_stripped(self):
result = LoopDetector._normalize_args(" hello world ")
assert result == "hello world"
def test_invalid_json_string(self):
result = LoopDetector._normalize_args("not json")
assert result == "not json"
def test_empty_dict(self):
result = LoopDetector._normalize_args({})
assert result == "{}"
def test_empty_string(self):
result = LoopDetector._normalize_args("")
assert result == ""
# ---------------------------------------------------------------------------
# LoopDetector — Recording and Detection
# ---------------------------------------------------------------------------
class TestLoopDetection:
"""Tests for loop recording and detection logic."""
def test_no_loop_below_threshold(self):
ld = LoopDetector(repetition_threshold=3)
ld.record_tool_call("search", {"q": "test"})
ld.record_tool_call("search", {"q": "test"})
assert ld.is_loop_detected() is False
def test_loop_at_threshold(self):
ld = LoopDetector(repetition_threshold=3)
for _ in range(3):
ld.record_tool_call("search", {"q": "test"})
assert ld.is_loop_detected() is True
def test_loop_above_threshold(self):
ld = LoopDetector(repetition_threshold=3)
for _ in range(5):
ld.record_tool_call("search", {"q": "test"})
assert ld.is_loop_detected() is True
def test_no_loop_with_different_tools(self):
ld = LoopDetector(repetition_threshold=3)
ld.record_tool_call("search", {"q": "test"})
ld.record_tool_call("fetch", {"url": "http://example.com"})
ld.record_tool_call("read", {"file": "data.txt"})
assert ld.is_loop_detected() is False
def test_no_loop_with_different_args(self):
ld = LoopDetector(repetition_threshold=3)
ld.record_tool_call("search", {"q": "test1"})
ld.record_tool_call("search", {"q": "test2"})
ld.record_tool_call("search", {"q": "test3"})
assert ld.is_loop_detected() is False
def test_loop_with_mixed_calls(self):
"""Loop detected even if other calls are interspersed, as long as
threshold is met within the window."""
ld = LoopDetector(window_size=5, repetition_threshold=3)
ld.record_tool_call("search", {"q": "test"})
ld.record_tool_call("fetch", {"url": "http://example.com"})
ld.record_tool_call("search", {"q": "test"})
ld.record_tool_call("search", {"q": "test"})
assert ld.is_loop_detected() is True
def test_window_overflow_removes_old_calls(self):
"""Old calls slide out of the window, potentially clearing the loop."""
ld = LoopDetector(window_size=3, repetition_threshold=3)
ld.record_tool_call("search", {"q": "test"})
ld.record_tool_call("search", {"q": "test"})
# Now add a different call — this pushes first "search" out when we add
# one more different call
ld.record_tool_call("fetch", {"url": "http://example.com"})
# Only 2 "search" in window at this point, not 3
assert ld.is_loop_detected() is False
def test_loop_with_string_args(self):
ld = LoopDetector(repetition_threshold=3)
for _ in range(3):
ld.record_tool_call("search", '{"q": "test"}')
assert ld.is_loop_detected() is True
def test_loop_with_equivalent_json_args(self):
"""Args with different key order should be normalized and treated as equal."""
ld = LoopDetector(repetition_threshold=3)
ld.record_tool_call("search", '{"b": 2, "a": 1}')
ld.record_tool_call("search", '{"a": 1, "b": 2}')
ld.record_tool_call("search", {"a": 1, "b": 2})
assert ld.is_loop_detected() is True
def test_empty_history(self):
ld = LoopDetector()
assert ld.is_loop_detected() is False
def test_single_call(self):
ld = LoopDetector()
ld.record_tool_call("search", {"q": "test"})
assert ld.is_loop_detected() is False
# ---------------------------------------------------------------------------
# LoopDetector — Reset
# ---------------------------------------------------------------------------
class TestLoopDetectorReset:
"""Tests for LoopDetector.reset()."""
def test_reset_clears_history(self):
ld = LoopDetector(repetition_threshold=3)
for _ in range(3):
ld.record_tool_call("search", {"q": "test"})
assert ld.is_loop_detected() is True
ld.reset()
assert ld.is_loop_detected() is False
def test_reset_allows_fresh_tracking(self):
ld = LoopDetector(repetition_threshold=3)
for _ in range(3):
ld.record_tool_call("search", {"q": "test"})
ld.reset()
# Now record again — should not trigger until threshold
ld.record_tool_call("search", {"q": "test"})
ld.record_tool_call("search", {"q": "test"})
assert ld.is_loop_detected() is False
# ---------------------------------------------------------------------------
# LoopDetector — Messages and Tool Info
# ---------------------------------------------------------------------------
class TestLoopDetectorMessages:
"""Tests for get_loop_message and get_repeated_tool_info."""
def test_get_loop_message_with_callable(self):
def my_callback(detector: LoopDetector) -> str:
return "Custom loop message"
ld = LoopDetector(on_loop=my_callback)
assert ld.get_loop_message() == "Custom loop message"
def test_get_loop_message_inject_reflection_returns_empty(self):
ld = LoopDetector(on_loop="inject_reflection")
assert ld.get_loop_message() == ""
def test_get_loop_message_stop_returns_empty(self):
ld = LoopDetector(on_loop="stop")
assert ld.get_loop_message() == ""
def test_get_repeated_tool_info_with_loop(self):
ld = LoopDetector(repetition_threshold=3)
for _ in range(3):
ld.record_tool_call("search", {"q": "test"})
info = ld.get_repeated_tool_info()
assert info is not None
assert "search" in info
def test_get_repeated_tool_info_no_loop(self):
ld = LoopDetector(repetition_threshold=3)
ld.record_tool_call("search", {"q": "test"})
assert ld.get_repeated_tool_info() is None
def test_get_repeated_tool_info_empty_history(self):
ld = LoopDetector()
assert ld.get_repeated_tool_info() is None
def test_callback_receives_detector_instance(self):
received = {}
def my_callback(detector: LoopDetector) -> str:
received["detector"] = detector
return "msg"
ld = LoopDetector(on_loop=my_callback)
ld.get_loop_message()
assert received["detector"] is ld
# ---------------------------------------------------------------------------
# LoopDetectedEvent
# ---------------------------------------------------------------------------
class TestLoopDetectedEvent:
"""Tests for the LoopDetectedEvent model."""
def test_event_creation(self):
event = LoopDetectedEvent(
agent_role="Researcher",
agent_id="agent-1",
task_id="task-1",
repeated_tool="search({\"q\": \"test\"})",
action_taken="inject_reflection",
iteration=5,
)
assert event.agent_role == "Researcher"
assert event.repeated_tool == 'search({"q": "test"})'
assert event.action_taken == "inject_reflection"
assert event.iteration == 5
assert event.type == "loop_detected"
def test_event_with_agent_fingerprint(self):
mock_agent = MagicMock()
mock_agent.fingerprint.uuid_str = "fp-123"
mock_agent.fingerprint.metadata = {"key": "value"}
event = LoopDetectedEvent(
agent_role="Researcher",
action_taken="stop",
iteration=3,
agent=mock_agent,
)
assert event.source_fingerprint == "fp-123"
assert event.fingerprint_metadata == {"key": "value"}
def test_event_without_agent(self):
event = LoopDetectedEvent(
agent_role="Researcher",
action_taken="inject_reflection",
iteration=1,
)
assert event.agent is None
assert event.agent_id is None
# ---------------------------------------------------------------------------
# Integration: CrewAgentExecutor._record_and_check_loop
# ---------------------------------------------------------------------------
class TestRecordAndCheckLoop:
"""Tests for the _record_and_check_loop helper on CrewAgentExecutor."""
@pytest.fixture
def _make_executor(self):
"""Factory that builds a minimal CrewAgentExecutor-like object.
We import the real class and patch its __init__ so we can
test _record_and_check_loop without setting up the full executor.
"""
from crewai.agents.crew_agent_executor import CrewAgentExecutor
def _factory(
loop_detector: LoopDetector | None = None,
verbose: bool = False,
) -> CrewAgentExecutor:
executor = object.__new__(CrewAgentExecutor)
# Minimal attributes required by _record_and_check_loop
executor.loop_detector = loop_detector
mock_agent = Mock()
mock_agent.role = "Test Agent"
mock_agent.id = "agent-1"
mock_agent.verbose = verbose
executor.agent = mock_agent
mock_task = Mock()
mock_task.id = "task-1"
executor.task = mock_task
executor.iterations = 0
executor.messages = []
mock_printer = Mock()
executor._printer = mock_printer
mock_i18n = Mock()
mock_i18n.slice.return_value = (
"WARNING: You appear to be repeating the same action ({repeated_tool}). "
"You have called it {count} times."
)
executor._i18n = mock_i18n
mock_llm = Mock()
executor.llm = mock_llm
executor.callbacks = []
return executor
return _factory
def test_no_loop_detector_returns_none(self, _make_executor):
executor = _make_executor(loop_detector=None)
result = executor._record_and_check_loop("search", {"q": "test"})
assert result is None
def test_no_loop_detected_returns_none(self, _make_executor):
ld = LoopDetector(repetition_threshold=3)
executor = _make_executor(loop_detector=ld)
# Only 1 call — no loop
result = executor._record_and_check_loop("search", {"q": "test"})
assert result is None
@patch("crewai.agents.crew_agent_executor.crewai_event_bus")
def test_inject_reflection_appends_message(self, mock_bus, _make_executor):
ld = LoopDetector(repetition_threshold=3, on_loop="inject_reflection")
executor = _make_executor(loop_detector=ld)
# Record 2 calls before the 3rd (which triggers detection)
ld.record_tool_call("search", {"q": "test"})
ld.record_tool_call("search", {"q": "test"})
result = executor._record_and_check_loop("search", {"q": "test"})
assert result is None # inject_reflection does NOT stop
assert len(executor.messages) == 1
assert "repeating" in executor.messages[0]["content"].lower()
# Event should have been emitted
mock_bus.emit.assert_called_once()
@patch("crewai.agents.crew_agent_executor.crewai_event_bus")
@patch("crewai.agents.crew_agent_executor.handle_max_iterations_exceeded")
def test_stop_returns_agent_finish(
self, mock_handle_max, mock_bus, _make_executor
):
from crewai.agents.parser import AgentFinish
mock_handle_max.return_value = AgentFinish(
thought="forced", output="Stopped", text="Stopped"
)
ld = LoopDetector(repetition_threshold=3, on_loop="stop")
executor = _make_executor(loop_detector=ld)
ld.record_tool_call("search", {"q": "test"})
ld.record_tool_call("search", {"q": "test"})
result = executor._record_and_check_loop("search", {"q": "test"})
assert result is not None
assert isinstance(result, AgentFinish)
mock_handle_max.assert_called_once()
mock_bus.emit.assert_called_once()
@patch("crewai.agents.crew_agent_executor.crewai_event_bus")
def test_callable_on_loop_injects_callback_message(
self, mock_bus, _make_executor
):
def custom_callback(detector: LoopDetector) -> str:
return "CUSTOM: Stop repeating yourself!"
ld = LoopDetector(repetition_threshold=3, on_loop=custom_callback)
executor = _make_executor(loop_detector=ld)
ld.record_tool_call("search", {"q": "test"})
ld.record_tool_call("search", {"q": "test"})
result = executor._record_and_check_loop("search", {"q": "test"})
assert result is None
assert len(executor.messages) == 1
assert "CUSTOM: Stop repeating yourself!" in executor.messages[0]["content"]
@patch("crewai.agents.crew_agent_executor.crewai_event_bus")
def test_detection_resets_after_intervention(self, mock_bus, _make_executor):
ld = LoopDetector(repetition_threshold=3, on_loop="inject_reflection")
executor = _make_executor(loop_detector=ld)
ld.record_tool_call("search", {"q": "test"})
ld.record_tool_call("search", {"q": "test"})
# 3rd call triggers loop
executor._record_and_check_loop("search", {"q": "test"})
# After intervention, detector should be reset
assert ld.is_loop_detected() is False
@patch("crewai.agents.crew_agent_executor.crewai_event_bus")
def test_verbose_prints_message(self, mock_bus, _make_executor):
ld = LoopDetector(repetition_threshold=3, on_loop="inject_reflection")
executor = _make_executor(loop_detector=ld, verbose=True)
ld.record_tool_call("search", {"q": "test"})
ld.record_tool_call("search", {"q": "test"})
executor._record_and_check_loop("search", {"q": "test"})
executor._printer.print.assert_called_once()
call_kwargs = executor._printer.print.call_args
assert "Loop detected" in call_kwargs.kwargs.get(
"content", call_kwargs[1].get("content", "")
)
@patch("crewai.agents.crew_agent_executor.crewai_event_bus")
def test_event_contains_correct_data(self, mock_bus, _make_executor):
ld = LoopDetector(repetition_threshold=3, on_loop="inject_reflection")
executor = _make_executor(loop_detector=ld)
ld.record_tool_call("search", {"q": "test"})
ld.record_tool_call("search", {"q": "test"})
executor._record_and_check_loop("search", {"q": "test"})
event = mock_bus.emit.call_args[0][1]
assert isinstance(event, LoopDetectedEvent)
assert event.agent_role == "Test Agent"
assert event.action_taken == "inject_reflection"
assert event.iteration == 0 # executor.iterations was 0
# ---------------------------------------------------------------------------
# Public API import
# ---------------------------------------------------------------------------
class TestPublicImport:
"""Verify LoopDetector is importable from the public crewai package."""
def test_import_from_crewai(self):
from crewai import LoopDetector as LD
assert LD is LoopDetector
def test_in_all(self):
import crewai
assert "LoopDetector" in crewai.__all__

View File

@@ -148,6 +148,8 @@ def test_mcp_tool_execution_in_sync_context(mock_tool_definitions):
mock_client.connect = AsyncMock()
mock_client.disconnect = AsyncMock()
mock_client.call_tool = AsyncMock(return_value="test result")
mock_client.connect_timeout = 30
mock_client.execution_timeout = 30
mock_client_class.return_value = mock_client
agent = Agent(
@@ -180,6 +182,8 @@ async def test_mcp_tool_execution_in_async_context(mock_tool_definitions):
mock_client.connect = AsyncMock()
mock_client.disconnect = AsyncMock()
mock_client.call_tool = AsyncMock(return_value="test result")
mock_client.connect_timeout = 30
mock_client.execution_timeout = 30
mock_client_class.return_value = mock_client
agent = Agent(

103
uv.lock generated
View File

@@ -1197,7 +1197,7 @@ requires-dist = [
{ name = "crewai-files", marker = "extra == 'file-processing'", editable = "lib/crewai-files" },
{ name = "crewai-tools", marker = "extra == 'tools'", editable = "lib/crewai-tools" },
{ name = "docling", marker = "extra == 'docling'", specifier = "~=2.75.0" },
{ name = "google-genai", marker = "extra == 'google-genai'", specifier = "~=1.65.0" },
{ name = "google-genai", marker = "extra == 'google-genai'", specifier = "~=1.49.0" },
{ name = "httpx", specifier = "~=0.28.1" },
{ name = "httpx-auth", marker = "extra == 'a2a'", specifier = "~=0.23.1" },
{ name = "httpx-sse", marker = "extra == 'a2a'", specifier = "~=0.4.0" },
@@ -1227,7 +1227,7 @@ requires-dist = [
{ name = "regex", specifier = "~=2026.1.15" },
{ name = "textual", specifier = ">=7.5.0" },
{ name = "tiktoken", marker = "extra == 'embeddings'", specifier = "~=0.8.0" },
{ name = "tokenizers", specifier = ">=0.21,<1" },
{ name = "tokenizers", specifier = "~=0.20.3" },
{ name = "tomli", specifier = "~=2.0.2" },
{ name = "tomli-w", specifier = "~=1.1.0" },
{ name = "uv", specifier = "~=0.9.13" },
@@ -2249,11 +2249,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/83/1d/d6466de3a5249d35e832a52834115ca9d1d0de6abc22065f049707516d47/google_auth-2.48.0-py3-none-any.whl", hash = "sha256:2e2a537873d449434252a9632c28bfc268b0adb1e53f9fb62afc5333a975903f", size = 236499, upload-time = "2026-01-26T19:22:45.099Z" },
]
[package.optional-dependencies]
requests = [
{ name = "requests" },
]
[[package]]
name = "google-cloud-vision"
version = "3.12.1"
@@ -2272,23 +2267,21 @@ wheels = [
[[package]]
name = "google-genai"
version = "1.65.0"
version = "1.49.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "distro" },
{ name = "google-auth", extra = ["requests"] },
{ name = "google-auth" },
{ name = "httpx" },
{ name = "pydantic" },
{ name = "requests" },
{ name = "sniffio" },
{ name = "tenacity" },
{ name = "typing-extensions" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/79/f9/cc1191c2540d6a4e24609a586c4ed45d2db57cfef47931c139ee70e5874a/google_genai-1.65.0.tar.gz", hash = "sha256:d470eb600af802d58a79c7f13342d9ea0d05d965007cae8f76c7adff3d7a4750", size = 497206, upload-time = "2026-02-26T00:20:33.824Z" }
sdist = { url = "https://files.pythonhosted.org/packages/82/49/1a724ee3c3748fa50721d53a52d9fee88c67d0c43bb16eb2b10ee89ab239/google_genai-1.49.0.tar.gz", hash = "sha256:35eb16023b72e298571ae30e919c810694f258f2ba68fc77a2185c7c8829ad5a", size = 253493, upload-time = "2025-11-05T22:41:03.278Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/68/3c/3fea4e7c91357c71782d7dcaad7a2577d636c90317e003386893c25bc62c/google_genai-1.65.0-py3-none-any.whl", hash = "sha256:68c025205856919bc03edb0155c11b4b833810b7ce17ad4b7a9eeba5158f6c44", size = 724429, upload-time = "2026-02-26T00:20:32.186Z" },
{ url = "https://files.pythonhosted.org/packages/d5/d3/84a152746dc7bdebb8ba0fd7d6157263044acd1d14b2a53e8df4a307b6b7/google_genai-1.49.0-py3-none-any.whl", hash = "sha256:ad49cd5be5b63397069e7aef9a4fe0a84cbdf25fcd93408e795292308db4ef32", size = 256098, upload-time = "2025-11-05T22:41:01.429Z" },
]
[[package]]
@@ -7650,32 +7643,68 @@ wheels = [
[[package]]
name = "tokenizers"
version = "0.22.2"
version = "0.20.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huggingface-hub" },
]
sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload-time = "2026-01-05T10:45:15.988Z" }
sdist = { url = "https://files.pythonhosted.org/packages/da/25/b1681c1c30ea3ea6e584ae3fffd552430b12faa599b558c4c4783f56d7ff/tokenizers-0.20.3.tar.gz", hash = "sha256:2278b34c5d0dd78e087e1ca7f9b1dcbf129d80211afa645f214bd6e051037539", size = 340513, upload-time = "2024-11-05T17:34:10.403Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" },
{ url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" },
{ url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" },
{ url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" },
{ url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" },
{ url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" },
{ url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" },
{ url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" },
{ url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" },
{ url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" },
{ url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" },
{ url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" },
{ url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" },
{ url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" },
{ url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" },
{ url = "https://files.pythonhosted.org/packages/84/04/655b79dbcc9b3ac5f1479f18e931a344af67e5b7d3b251d2dcdcd7558592/tokenizers-0.22.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:753d47ebd4542742ef9261d9da92cd545b2cacbb48349a1225466745bb866ec4", size = 3282301, upload-time = "2026-01-05T10:40:34.858Z" },
{ url = "https://files.pythonhosted.org/packages/46/cd/e4851401f3d8f6f45d8480262ab6a5c8cb9c4302a790a35aa14eeed6d2fd/tokenizers-0.22.2-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e10bf9113d209be7cd046d40fbabbaf3278ff6d18eb4da4c500443185dc1896c", size = 3161308, upload-time = "2026-01-05T10:40:40.737Z" },
{ url = "https://files.pythonhosted.org/packages/6f/6e/55553992a89982cd12d4a66dddb5e02126c58677ea3931efcbe601d419db/tokenizers-0.22.2-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:64d94e84f6660764e64e7e0b22baa72f6cd942279fdbb21d46abd70d179f0195", size = 3718964, upload-time = "2026-01-05T10:40:46.56Z" },
{ url = "https://files.pythonhosted.org/packages/59/8c/b1c87148aa15e099243ec9f0cf9d0e970cc2234c3257d558c25a2c5304e6/tokenizers-0.22.2-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f01a9c019878532f98927d2bacb79bbb404b43d3437455522a00a30718cdedb5", size = 3373542, upload-time = "2026-01-05T10:40:52.803Z" },
{ url = "https://files.pythonhosted.org/packages/c8/51/421bb0052fc4333f7c1e3231d8c6607552933d919b628c8fabd06f60ba1e/tokenizers-0.20.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:31ccab28dbb1a9fe539787210b0026e22debeab1662970f61c2d921f7557f7e4", size = 2674308, upload-time = "2024-11-05T17:30:25.423Z" },
{ url = "https://files.pythonhosted.org/packages/a6/e9/f651f8d27614fd59af387f4dfa568b55207e5fac8d06eec106dc00b921c4/tokenizers-0.20.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c6361191f762bda98c773da418cf511cbaa0cb8d0a1196f16f8c0119bde68ff8", size = 2559363, upload-time = "2024-11-05T17:30:28.841Z" },
{ url = "https://files.pythonhosted.org/packages/e3/e8/0e9f81a09ab79f409eabfd99391ca519e315496694671bebca24c3e90448/tokenizers-0.20.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f128d5da1202b78fa0a10d8d938610472487da01b57098d48f7e944384362514", size = 2892896, upload-time = "2024-11-05T17:30:30.429Z" },
{ url = "https://files.pythonhosted.org/packages/b0/72/15fdbc149e05005e99431ecd471807db2241983deafe1e704020f608f40e/tokenizers-0.20.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79c4121a2e9433ad7ef0769b9ca1f7dd7fa4c0cd501763d0a030afcbc6384481", size = 2802785, upload-time = "2024-11-05T17:30:32.045Z" },
{ url = "https://files.pythonhosted.org/packages/26/44/1f8aea48f9bb117d966b7272484671b33a509f6217a8e8544d79442c90db/tokenizers-0.20.3-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7850fde24197fe5cd6556e2fdba53a6d3bae67c531ea33a3d7c420b90904141", size = 3086060, upload-time = "2024-11-05T17:30:34.11Z" },
{ url = "https://files.pythonhosted.org/packages/2e/83/82ba40da99870b3a0b801cffaf4f099f088a84c7e07d32cc6ca751ce08e6/tokenizers-0.20.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b357970c095dc134978a68c67d845a1e3803ab7c4fbb39195bde914e7e13cf8b", size = 3096760, upload-time = "2024-11-05T17:30:36.276Z" },
{ url = "https://files.pythonhosted.org/packages/f3/46/7a025404201d937f86548928616c0a164308aa3998e546efdf798bf5ee9c/tokenizers-0.20.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a333d878c4970b72d6c07848b90c05f6b045cf9273fc2bc04a27211721ad6118", size = 3380165, upload-time = "2024-11-05T17:30:37.642Z" },
{ url = "https://files.pythonhosted.org/packages/aa/49/15fae66ac62e49255eeedbb7f4127564b2c3f3aef2009913f525732d1a08/tokenizers-0.20.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1fd9fee817f655a8f50049f685e224828abfadd436b8ff67979fc1d054b435f1", size = 2994038, upload-time = "2024-11-05T17:30:40.075Z" },
{ url = "https://files.pythonhosted.org/packages/f4/64/693afc9ba2393c2eed85c02bacb44762f06a29f0d1a5591fa5b40b39c0a2/tokenizers-0.20.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9e7816808b402129393a435ea2a509679b41246175d6e5e9f25b8692bfaa272b", size = 8977285, upload-time = "2024-11-05T17:30:42.095Z" },
{ url = "https://files.pythonhosted.org/packages/be/7e/6126c18694310fe07970717929e889898767c41fbdd95b9078e8aec0f9ef/tokenizers-0.20.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ba96367db9d8a730d3a1d5996b4b7babb846c3994b8ef14008cd8660f55db59d", size = 9294890, upload-time = "2024-11-05T17:30:44.563Z" },
{ url = "https://files.pythonhosted.org/packages/71/7d/5e3307a1091c8608a1e58043dff49521bc19553c6e9548c7fac6840cc2c4/tokenizers-0.20.3-cp310-none-win32.whl", hash = "sha256:ee31ba9d7df6a98619426283e80c6359f167e2e9882d9ce1b0254937dbd32f3f", size = 2196883, upload-time = "2024-11-05T17:30:46.792Z" },
{ url = "https://files.pythonhosted.org/packages/47/62/aaf5b2a526b3b10c20985d9568ff8c8f27159345eaef3347831e78cd5894/tokenizers-0.20.3-cp310-none-win_amd64.whl", hash = "sha256:a845c08fdad554fe0871d1255df85772f91236e5fd6b9287ef8b64f5807dbd0c", size = 2381637, upload-time = "2024-11-05T17:30:48.156Z" },
{ url = "https://files.pythonhosted.org/packages/c6/93/6742ef9206409d5ce1fdf44d5ca1687cdc3847ba0485424e2c731e6bcf67/tokenizers-0.20.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:585b51e06ca1f4839ce7759941e66766d7b060dccfdc57c4ca1e5b9a33013a90", size = 2674224, upload-time = "2024-11-05T17:30:49.972Z" },
{ url = "https://files.pythonhosted.org/packages/aa/14/e75ece72e99f6ef9ae07777ca9fdd78608f69466a5cecf636e9bd2f25d5c/tokenizers-0.20.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:61cbf11954f3b481d08723ebd048ba4b11e582986f9be74d2c3bdd9293a4538d", size = 2558991, upload-time = "2024-11-05T17:30:51.666Z" },
{ url = "https://files.pythonhosted.org/packages/46/54/033b5b2ba0c3ae01e026c6f7ced147d41a2fa1c573d00a66cb97f6d7f9b3/tokenizers-0.20.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef820880d5e4e8484e2fa54ff8d297bb32519eaa7815694dc835ace9130a3eea", size = 2892476, upload-time = "2024-11-05T17:30:53.505Z" },
{ url = "https://files.pythonhosted.org/packages/e6/b0/cc369fb3297d61f3311cab523d16d48c869dc2f0ba32985dbf03ff811041/tokenizers-0.20.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:67ef4dcb8841a4988cd00dd288fb95dfc8e22ed021f01f37348fd51c2b055ba9", size = 2802775, upload-time = "2024-11-05T17:30:55.229Z" },
{ url = "https://files.pythonhosted.org/packages/1a/74/62ad983e8ea6a63e04ed9c5be0b605056bf8aac2f0125f9b5e0b3e2b89fa/tokenizers-0.20.3-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff1ef8bd47a02b0dc191688ccb4da53600df5d4c9a05a4b68e1e3de4823e78eb", size = 3086138, upload-time = "2024-11-05T17:30:57.332Z" },
{ url = "https://files.pythonhosted.org/packages/6b/ac/4637ba619db25094998523f9e6f5b456e1db1f8faa770a3d925d436db0c3/tokenizers-0.20.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:444d188186eab3148baf0615b522461b41b1f0cd58cd57b862ec94b6ac9780f1", size = 3098076, upload-time = "2024-11-05T17:30:59.455Z" },
{ url = "https://files.pythonhosted.org/packages/58/ce/9793f2dc2ce529369807c9c74e42722b05034af411d60f5730b720388c7d/tokenizers-0.20.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:37c04c032c1442740b2c2d925f1857885c07619224a533123ac7ea71ca5713da", size = 3379650, upload-time = "2024-11-05T17:31:01.264Z" },
{ url = "https://files.pythonhosted.org/packages/50/f6/2841de926bc4118af996eaf0bdf0ea5b012245044766ffc0347e6c968e63/tokenizers-0.20.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:453c7769d22231960ee0e883d1005c93c68015025a5e4ae56275406d94a3c907", size = 2994005, upload-time = "2024-11-05T17:31:02.985Z" },
{ url = "https://files.pythonhosted.org/packages/a3/b2/00915c4fed08e9505d37cf6eaab45b12b4bff8f6719d459abcb9ead86a4b/tokenizers-0.20.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4bb31f7b2847e439766aaa9cc7bccf7ac7088052deccdb2275c952d96f691c6a", size = 8977488, upload-time = "2024-11-05T17:31:04.424Z" },
{ url = "https://files.pythonhosted.org/packages/e9/ac/1c069e7808181ff57bcf2d39e9b6fbee9133a55410e6ebdaa89f67c32e83/tokenizers-0.20.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:843729bf0f991b29655a069a2ff58a4c24375a553c70955e15e37a90dd4e045c", size = 9294935, upload-time = "2024-11-05T17:31:06.882Z" },
{ url = "https://files.pythonhosted.org/packages/50/47/722feb70ee68d1c4412b12d0ea4acc2713179fd63f054913990f9e259492/tokenizers-0.20.3-cp311-none-win32.whl", hash = "sha256:efcce3a927b1e20ca694ba13f7a68c59b0bd859ef71e441db68ee42cf20c2442", size = 2197175, upload-time = "2024-11-05T17:31:09.385Z" },
{ url = "https://files.pythonhosted.org/packages/75/68/1b4f928b15a36ed278332ac75d66d7eb65d865bf344d049c452c18447bf9/tokenizers-0.20.3-cp311-none-win_amd64.whl", hash = "sha256:88301aa0801f225725b6df5dea3d77c80365ff2362ca7e252583f2b4809c4cc0", size = 2381616, upload-time = "2024-11-05T17:31:10.685Z" },
{ url = "https://files.pythonhosted.org/packages/07/00/92a08af2a6b0c88c50f1ab47d7189e695722ad9714b0ee78ea5e1e2e1def/tokenizers-0.20.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:49d12a32e190fad0e79e5bdb788d05da2f20d8e006b13a70859ac47fecf6ab2f", size = 2667951, upload-time = "2024-11-05T17:31:12.356Z" },
{ url = "https://files.pythonhosted.org/packages/ec/9a/e17a352f0bffbf415cf7d73756f5c73a3219225fc5957bc2f39d52c61684/tokenizers-0.20.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:282848cacfb9c06d5e51489f38ec5aa0b3cd1e247a023061945f71f41d949d73", size = 2555167, upload-time = "2024-11-05T17:31:13.839Z" },
{ url = "https://files.pythonhosted.org/packages/27/37/d108df55daf4f0fcf1f58554692ff71687c273d870a34693066f0847be96/tokenizers-0.20.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abe4e08c7d0cd6154c795deb5bf81d2122f36daf075e0c12a8b050d824ef0a64", size = 2898389, upload-time = "2024-11-05T17:31:15.12Z" },
{ url = "https://files.pythonhosted.org/packages/b2/27/32f29da16d28f59472fa7fb38e7782069748c7e9ab9854522db20341624c/tokenizers-0.20.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca94fc1b73b3883c98f0c88c77700b13d55b49f1071dfd57df2b06f3ff7afd64", size = 2795866, upload-time = "2024-11-05T17:31:16.857Z" },
{ url = "https://files.pythonhosted.org/packages/29/4e/8a9a3c89e128c4a40f247b501c10279d2d7ade685953407c4d94c8c0f7a7/tokenizers-0.20.3-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef279c7e239f95c8bdd6ff319d9870f30f0d24915b04895f55b1adcf96d6c60d", size = 3085446, upload-time = "2024-11-05T17:31:18.392Z" },
{ url = "https://files.pythonhosted.org/packages/b4/3b/a2a7962c496ebcd95860ca99e423254f760f382cd4bd376f8895783afaf5/tokenizers-0.20.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16384073973f6ccbde9852157a4fdfe632bb65208139c9d0c0bd0176a71fd67f", size = 3094378, upload-time = "2024-11-05T17:31:20.329Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f4/a8a33f0192a1629a3bd0afcad17d4d221bbf9276da4b95d226364208d5eb/tokenizers-0.20.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:312d522caeb8a1a42ebdec87118d99b22667782b67898a76c963c058a7e41d4f", size = 3385755, upload-time = "2024-11-05T17:31:21.778Z" },
{ url = "https://files.pythonhosted.org/packages/9e/65/c83cb3545a65a9eaa2e13b22c93d5e00bd7624b354a44adbdc93d5d9bd91/tokenizers-0.20.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2b7cb962564785a83dafbba0144ecb7f579f1d57d8c406cdaa7f32fe32f18ad", size = 2997679, upload-time = "2024-11-05T17:31:23.134Z" },
{ url = "https://files.pythonhosted.org/packages/55/e9/a80d4e592307688a67c7c59ab77e03687b6a8bd92eb5db763a2c80f93f57/tokenizers-0.20.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:124c5882ebb88dadae1fc788a582299fcd3a8bd84fc3e260b9918cf28b8751f5", size = 8989296, upload-time = "2024-11-05T17:31:24.953Z" },
{ url = "https://files.pythonhosted.org/packages/90/af/60c957af8d2244321124e893828f1a4817cde1a2d08d09d423b73f19bd2f/tokenizers-0.20.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2b6e54e71f84c4202111a489879005cb14b92616a87417f6c102c833af961ea2", size = 9303621, upload-time = "2024-11-05T17:31:27.341Z" },
{ url = "https://files.pythonhosted.org/packages/be/a9/96172310ee141009646d63a1ca267c099c462d747fe5ef7e33f74e27a683/tokenizers-0.20.3-cp312-none-win32.whl", hash = "sha256:83d9bfbe9af86f2d9df4833c22e94d94750f1d0cd9bfb22a7bb90a86f61cdb1c", size = 2188979, upload-time = "2024-11-05T17:31:29.483Z" },
{ url = "https://files.pythonhosted.org/packages/bd/68/61d85ae7ae96dde7d0974ff3538db75d5cdc29be2e4329cd7fc51a283e22/tokenizers-0.20.3-cp312-none-win_amd64.whl", hash = "sha256:44def74cee574d609a36e17c8914311d1b5dbcfe37c55fd29369d42591b91cf2", size = 2380725, upload-time = "2024-11-05T17:31:31.315Z" },
{ url = "https://files.pythonhosted.org/packages/07/19/36e9eaafb229616cb8502b42030fa7fe347550e76cb618de71b498fc3222/tokenizers-0.20.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e0b630e0b536ef0e3c8b42c685c1bc93bd19e98c0f1543db52911f8ede42cf84", size = 2666813, upload-time = "2024-11-05T17:31:32.783Z" },
{ url = "https://files.pythonhosted.org/packages/b9/c7/e2ce1d4f756c8a62ef93fdb4df877c2185339b6d63667b015bf70ea9d34b/tokenizers-0.20.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a02d160d2b19bcbfdf28bd9a4bf11be4cb97d0499c000d95d4c4b1a4312740b6", size = 2555354, upload-time = "2024-11-05T17:31:34.208Z" },
{ url = "https://files.pythonhosted.org/packages/7c/cf/5309c2d173a6a67f9ec8697d8e710ea32418de6fd8541778032c202a1c3e/tokenizers-0.20.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e3d80d89b068bc30034034b5319218c7c0a91b00af19679833f55f3becb6945", size = 2897745, upload-time = "2024-11-05T17:31:35.733Z" },
{ url = "https://files.pythonhosted.org/packages/2c/e5/af3078e32f225e680e69d61f78855880edb8d53f5850a1834d519b2b103f/tokenizers-0.20.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:174a54910bed1b089226512b4458ea60d6d6fd93060254734d3bc3540953c51c", size = 2794385, upload-time = "2024-11-05T17:31:37.497Z" },
{ url = "https://files.pythonhosted.org/packages/0b/a7/bc421fe46650cc4eb4a913a236b88c243204f32c7480684d2f138925899e/tokenizers-0.20.3-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:098b8a632b8656aa5802c46689462c5c48f02510f24029d71c208ec2c822e771", size = 3084580, upload-time = "2024-11-05T17:31:39.456Z" },
{ url = "https://files.pythonhosted.org/packages/c6/22/97e1e95ee81f75922c9f569c23cb2b1fdc7f5a7a29c4c9fae17e63f751a6/tokenizers-0.20.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:78c8c143e3ae41e718588281eb3e212c2b31623c9d6d40410ec464d7d6221fb5", size = 3093581, upload-time = "2024-11-05T17:31:41.224Z" },
{ url = "https://files.pythonhosted.org/packages/d5/14/f0df0ee3b9e516121e23c0099bccd7b9f086ba9150021a750e99b16ce56f/tokenizers-0.20.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b26b0aadb18cd8701077362ba359a06683662d5cafe3e8e8aba10eb05c037f1", size = 3385934, upload-time = "2024-11-05T17:31:43.811Z" },
{ url = "https://files.pythonhosted.org/packages/66/52/7a171bd4929e3ffe61a29b4340fe5b73484709f92a8162a18946e124c34c/tokenizers-0.20.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07d7851a72717321022f3774e84aa9d595a041d643fafa2e87fbc9b18711dac0", size = 2997311, upload-time = "2024-11-05T17:31:46.224Z" },
{ url = "https://files.pythonhosted.org/packages/7c/64/f1993bb8ebf775d56875ca0d50a50f2648bfbbb143da92fe2e6ceeb4abd5/tokenizers-0.20.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:bd44e48a430ada902c6266a8245f5036c4fe744fcb51f699999fbe82aa438797", size = 8988601, upload-time = "2024-11-05T17:31:47.907Z" },
{ url = "https://files.pythonhosted.org/packages/d6/3f/49fa63422159bbc2f2a4ac5bfc597d04d4ec0ad3d2ef46649b5e9a340e37/tokenizers-0.20.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a4c186bb006ccbe1f5cc4e0380d1ce7806f5955c244074fd96abc55e27b77f01", size = 9303950, upload-time = "2024-11-05T17:31:50.674Z" },
{ url = "https://files.pythonhosted.org/packages/66/11/79d91aeb2817ad1993ef61c690afe73e6dbedbfb21918b302ef5a2ba9bfb/tokenizers-0.20.3-cp313-none-win32.whl", hash = "sha256:6e19e0f1d854d6ab7ea0c743d06e764d1d9a546932be0a67f33087645f00fe13", size = 2188941, upload-time = "2024-11-05T17:31:53.334Z" },
{ url = "https://files.pythonhosted.org/packages/c2/ff/ac8410f868fb8b14b5e619efa304aa119cb8a40bd7df29fc81a898e64f99/tokenizers-0.20.3-cp313-none-win_amd64.whl", hash = "sha256:d50ede425c7e60966a9680d41b58b3a0950afa1bb570488e2972fa61662c4273", size = 2380269, upload-time = "2024-11-05T17:31:54.796Z" },
{ url = "https://files.pythonhosted.org/packages/29/cd/ff1586dd572aaf1637d59968df3f6f6532fa255f4638fbc29f6d27e0b690/tokenizers-0.20.3-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e919f2e3e68bb51dc31de4fcbbeff3bdf9c1cad489044c75e2b982a91059bd3c", size = 2672044, upload-time = "2024-11-05T17:33:07.796Z" },
{ url = "https://files.pythonhosted.org/packages/b5/9e/7a2c00abbc8edb021ee0b1f12aab76a7b7824b49f94bcd9f075d0818d4b0/tokenizers-0.20.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b8e9608f2773996cc272156e305bd79066163a66b0390fe21750aff62df1ac07", size = 2558841, upload-time = "2024-11-05T17:33:09.542Z" },
{ url = "https://files.pythonhosted.org/packages/8e/c1/6af62ef61316f33ecf785bbb2bee4292f34ea62b491d4480ad9b09acf6b6/tokenizers-0.20.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:39270a7050deaf50f7caff4c532c01b3c48f6608d42b3eacdebdc6795478c8df", size = 2897936, upload-time = "2024-11-05T17:33:11.413Z" },
{ url = "https://files.pythonhosted.org/packages/9a/0b/c076b2ff3ee6dc70c805181fbe325668b89cfee856f8dfa24cc9aa293c84/tokenizers-0.20.3-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e005466632b1c5d2d2120f6de8aa768cc9d36cd1ab7d51d0c27a114c91a1e6ee", size = 3082688, upload-time = "2024-11-05T17:33:13.538Z" },
{ url = "https://files.pythonhosted.org/packages/0a/60/56510124933136c2e90879e1c81603cfa753ae5a87830e3ef95056b20d8f/tokenizers-0.20.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a07962340b36189b6c8feda552ea1bfeee6cf067ff922a1d7760662c2ee229e5", size = 2998924, upload-time = "2024-11-05T17:33:16.249Z" },
{ url = "https://files.pythonhosted.org/packages/68/60/4107b618b7b9155cb34ad2e0fc90946b7e71f041b642122fb6314f660688/tokenizers-0.20.3-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:55046ad3dd5f2b3c67501fcc8c9cbe3e901d8355f08a3b745e9b57894855f85b", size = 8989514, upload-time = "2024-11-05T17:33:18.161Z" },
{ url = "https://files.pythonhosted.org/packages/e8/bd/48475818e614b73316baf37ac1e4e51b578bbdf58651812d7e55f43b88d8/tokenizers-0.20.3-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:efcf0eb939988b627558aaf2b9dc3e56d759cad2e0cfa04fcab378e4b48fc4fd", size = 9303476, upload-time = "2024-11-05T17:33:21.251Z" },
]
[[package]]
@@ -7829,7 +7858,7 @@ wheels = [
[[package]]
name = "transformers"
version = "4.57.6"
version = "4.46.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "filelock" },
@@ -7843,9 +7872,9 @@ dependencies = [
{ name = "tokenizers" },
{ name = "tqdm" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c4/35/67252acc1b929dc88b6602e8c4a982e64f31e733b804c14bc24b47da35e6/transformers-4.57.6.tar.gz", hash = "sha256:55e44126ece9dc0a291521b7e5492b572e6ef2766338a610b9ab5afbb70689d3", size = 10134912, upload-time = "2026-01-16T10:38:39.284Z" }
sdist = { url = "https://files.pythonhosted.org/packages/37/5a/58f96c83e566f907ae39f16d4401bbefd8bb85c60bd1e6a95c419752ab90/transformers-4.46.3.tar.gz", hash = "sha256:8ee4b3ae943fe33e82afff8e837f4b052058b07ca9be3cb5b729ed31295f72cc", size = 8627944, upload-time = "2024-11-18T22:13:01.012Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/03/b8/e484ef633af3887baeeb4b6ad12743363af7cce68ae51e938e00aaa0529d/transformers-4.57.6-py3-none-any.whl", hash = "sha256:4c9e9de11333ddfe5114bc872c9f370509198acf0b87a832a0ab9458e2bd0550", size = 11993498, upload-time = "2026-01-16T10:38:31.289Z" },
{ url = "https://files.pythonhosted.org/packages/51/51/b87caa939fedf307496e4dbf412f4b909af3d9ca8b189fc3b65c1faa456f/transformers-4.46.3-py3-none-any.whl", hash = "sha256:a12ef6f52841fd190a3e5602145b542d03507222f2c64ebb7ee92e8788093aef", size = 10034536, upload-time = "2024-11-18T22:12:57.024Z" },
]
[[package]]