mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-04-17 00:12:36 +00:00
Compare commits
27 Commits
1.0.0b3
...
lorenze/na
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
505e3ceea5 | ||
|
|
881b5befad | ||
|
|
97ecd327a8 | ||
|
|
84a3cbad7c | ||
|
|
81cd269318 | ||
|
|
f3da80a1f1 | ||
|
|
037e2b4631 | ||
|
|
fbd72ded44 | ||
|
|
83bc40eefe | ||
|
|
57052b94d3 | ||
|
|
18943babff | ||
|
|
fa5a901d93 | ||
|
|
7e8d33104a | ||
|
|
8a9835a59f | ||
|
|
7a59769c7e | ||
|
|
ba30374ac4 | ||
|
|
6150a358a3 | ||
|
|
61e3ec2e6f | ||
|
|
c5455142c3 | ||
|
|
44bbccdb75 | ||
|
|
0073b4206f | ||
|
|
dcd57ccc9f | ||
|
|
38e7a37485 | ||
|
|
21ba6d5b54 | ||
|
|
97c2cbd110 | ||
|
|
7045ed389a | ||
|
|
3fc1381e76 |
@@ -4,22 +4,18 @@ repos:
|
||||
- id: ruff
|
||||
name: ruff
|
||||
entry: uv run ruff check
|
||||
args: ["--config", "pyproject.toml", "."]
|
||||
language: system
|
||||
pass_filenames: false
|
||||
types: [python]
|
||||
exclude: ^lib/crewai/
|
||||
- id: ruff-format
|
||||
name: ruff-format
|
||||
entry: uv run ruff format
|
||||
args: ["--config", "pyproject.toml", "."]
|
||||
language: system
|
||||
pass_filenames: false
|
||||
types: [python]
|
||||
exclude: ^lib/crewai/
|
||||
- id: mypy
|
||||
name: mypy
|
||||
entry: uv run mypy
|
||||
args: ["--config-file", "pyproject.toml", "."]
|
||||
language: system
|
||||
pass_filenames: false
|
||||
types: [python]
|
||||
|
||||
exclude: ^lib/crewai/
|
||||
|
||||
@@ -12,7 +12,7 @@ dependencies = [
|
||||
"pytube>=15.0.0",
|
||||
"requests>=2.32.5",
|
||||
"docker>=7.1.0",
|
||||
"crewai==1.0.0b3",
|
||||
"crewai==1.0.0b2",
|
||||
"lancedb>=0.5.4",
|
||||
"tiktoken>=0.8.0",
|
||||
"beautifulsoup4>=4.13.4",
|
||||
|
||||
@@ -291,4 +291,4 @@ __all__ = [
|
||||
"ZapierActionTools",
|
||||
]
|
||||
|
||||
__version__ = "1.0.0b3"
|
||||
__version__ = "1.0.0b2"
|
||||
|
||||
@@ -48,7 +48,7 @@ Repository = "https://github.com/crewAIInc/crewAI"
|
||||
|
||||
[project.optional-dependencies]
|
||||
tools = [
|
||||
"crewai-tools==1.0.0b3",
|
||||
"crewai-tools==1.0.0b2",
|
||||
]
|
||||
embeddings = [
|
||||
"tiktoken~=0.8.0"
|
||||
@@ -84,18 +84,9 @@ voyageai = [
|
||||
litellm = [
|
||||
"litellm>=1.74.9",
|
||||
]
|
||||
bedrock = [
|
||||
boto3 = [
|
||||
"boto3>=1.40.45",
|
||||
]
|
||||
google-genai = [
|
||||
"google-genai>=1.2.0",
|
||||
]
|
||||
azure-ai-inference = [
|
||||
"azure-ai-inference>=1.0.0b9",
|
||||
]
|
||||
anthropic = [
|
||||
"anthropic>=0.69.0",
|
||||
]
|
||||
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -40,7 +40,7 @@ def _suppress_pydantic_deprecation_warnings() -> None:
|
||||
|
||||
_suppress_pydantic_deprecation_warnings()
|
||||
|
||||
__version__ = "1.0.0b3"
|
||||
__version__ = "1.0.0b2"
|
||||
_telemetry_submitted = False
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from collections.abc import Callable, Sequence
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from collections.abc import Callable, Sequence
|
||||
from typing import (
|
||||
Any,
|
||||
Literal,
|
||||
@@ -876,7 +876,6 @@ class Agent(BaseAgent):
|
||||
i18n=self.i18n,
|
||||
original_agent=self,
|
||||
guardrail=self.guardrail,
|
||||
guardrail_max_retries=self.guardrail_max_retries,
|
||||
)
|
||||
|
||||
return await lite_agent.kickoff_async(messages)
|
||||
|
||||
@@ -53,6 +53,7 @@ from crewai.task import Task
|
||||
from crewai.telemetry.telemetry import Telemetry
|
||||
from crewai.utilities import Logger
|
||||
from crewai.utilities.constants import EMITTER_COLOR
|
||||
from crewai.utilities.printer import Printer
|
||||
|
||||
from .listeners.memory_listener import MemoryListener
|
||||
from .types.flow_events import (
|
||||
@@ -75,6 +76,8 @@ from .types.tool_usage_events import (
|
||||
ToolUsageStartedEvent,
|
||||
)
|
||||
|
||||
_printer = Printer()
|
||||
|
||||
|
||||
class EventListener(BaseEventListener):
|
||||
_instance = None
|
||||
@@ -378,8 +381,12 @@ class EventListener(BaseEventListener):
|
||||
@crewai_event_bus.on(LLMStreamChunkEvent)
|
||||
def on_llm_stream_chunk(source, event: LLMStreamChunkEvent):
|
||||
self.text_stream.write(event.chunk)
|
||||
|
||||
self.text_stream.seek(self.next_chunk)
|
||||
self.text_stream.read()
|
||||
|
||||
# Read from the in-memory stream
|
||||
content = self.text_stream.read()
|
||||
_printer.print(content)
|
||||
self.next_chunk = self.text_stream.tell()
|
||||
|
||||
# ----------- LLM GUARDRAIL EVENTS -----------
|
||||
|
||||
@@ -97,7 +97,7 @@ class AgentEvaluator:
|
||||
)
|
||||
|
||||
if not trace:
|
||||
trace = {}
|
||||
return
|
||||
|
||||
result = self.evaluate(
|
||||
agent=agent,
|
||||
@@ -151,7 +151,7 @@ class AgentEvaluator:
|
||||
)
|
||||
|
||||
if not trace:
|
||||
trace = {}
|
||||
return
|
||||
|
||||
result = self.evaluate(
|
||||
agent=target_agent,
|
||||
|
||||
@@ -32,7 +32,7 @@ from crewai.flow.flow_visualizer import plot_flow
|
||||
from crewai.flow.persistence.base import FlowPersistence
|
||||
from crewai.flow.types import FlowExecutionData
|
||||
from crewai.flow.utils import get_possible_return_constants
|
||||
from crewai.utilities.printer import Printer, PrinterColor
|
||||
from crewai.utilities.printer import Printer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -56,6 +56,19 @@ StateT = TypeVar(
|
||||
def ensure_state_type(state: Any, expected_type: type[StateT]) -> StateT:
|
||||
"""Ensure state matches expected type with proper validation.
|
||||
|
||||
Args:
|
||||
state: State instance to validate
|
||||
expected_type: Expected type for the state
|
||||
|
||||
Returns:
|
||||
Validated state instance
|
||||
|
||||
Raises:
|
||||
TypeError: If state doesn't match expected type
|
||||
ValueError: If state validation fails
|
||||
"""
|
||||
"""Ensure state matches expected type with proper validation.
|
||||
|
||||
Args:
|
||||
state: State instance to validate
|
||||
expected_type: Expected type for the state
|
||||
@@ -93,7 +106,7 @@ def start(condition: str | dict | Callable | None = None) -> Callable:
|
||||
condition : Optional[Union[str, dict, Callable]], optional
|
||||
Defines when the start method should execute. Can be:
|
||||
- str: Name of a method that triggers this start
|
||||
- dict: Result from or_() or and_(), including nested conditions
|
||||
- dict: Contains "type" ("AND"/"OR") and "methods" (list of triggers)
|
||||
- Callable: A method reference that triggers this start
|
||||
Default is None, meaning unconditional start.
|
||||
|
||||
@@ -128,18 +141,13 @@ def start(condition: str | dict | Callable | None = None) -> Callable:
|
||||
if isinstance(condition, str):
|
||||
func.__trigger_methods__ = [condition]
|
||||
func.__condition_type__ = "OR"
|
||||
elif isinstance(condition, dict) and "type" in condition:
|
||||
if "conditions" in condition:
|
||||
func.__trigger_condition__ = condition
|
||||
func.__trigger_methods__ = _extract_all_methods(condition)
|
||||
func.__condition_type__ = condition["type"]
|
||||
elif "methods" in condition:
|
||||
func.__trigger_methods__ = condition["methods"]
|
||||
func.__condition_type__ = condition["type"]
|
||||
else:
|
||||
raise ValueError(
|
||||
"Condition dict must contain 'conditions' or 'methods'"
|
||||
)
|
||||
elif (
|
||||
isinstance(condition, dict)
|
||||
and "type" in condition
|
||||
and "methods" in condition
|
||||
):
|
||||
func.__trigger_methods__ = condition["methods"]
|
||||
func.__condition_type__ = condition["type"]
|
||||
elif callable(condition) and hasattr(condition, "__name__"):
|
||||
func.__trigger_methods__ = [condition.__name__]
|
||||
func.__condition_type__ = "OR"
|
||||
@@ -165,7 +173,7 @@ def listen(condition: str | dict | Callable) -> Callable:
|
||||
condition : Union[str, dict, Callable]
|
||||
Specifies when the listener should execute. Can be:
|
||||
- str: Name of a method that triggers this listener
|
||||
- dict: Result from or_() or and_(), including nested conditions
|
||||
- dict: Contains "type" ("AND"/"OR") and "methods" (list of triggers)
|
||||
- Callable: A method reference that triggers this listener
|
||||
|
||||
Returns
|
||||
@@ -193,18 +201,13 @@ def listen(condition: str | dict | Callable) -> Callable:
|
||||
if isinstance(condition, str):
|
||||
func.__trigger_methods__ = [condition]
|
||||
func.__condition_type__ = "OR"
|
||||
elif isinstance(condition, dict) and "type" in condition:
|
||||
if "conditions" in condition:
|
||||
func.__trigger_condition__ = condition
|
||||
func.__trigger_methods__ = _extract_all_methods(condition)
|
||||
func.__condition_type__ = condition["type"]
|
||||
elif "methods" in condition:
|
||||
func.__trigger_methods__ = condition["methods"]
|
||||
func.__condition_type__ = condition["type"]
|
||||
else:
|
||||
raise ValueError(
|
||||
"Condition dict must contain 'conditions' or 'methods'"
|
||||
)
|
||||
elif (
|
||||
isinstance(condition, dict)
|
||||
and "type" in condition
|
||||
and "methods" in condition
|
||||
):
|
||||
func.__trigger_methods__ = condition["methods"]
|
||||
func.__condition_type__ = condition["type"]
|
||||
elif callable(condition) and hasattr(condition, "__name__"):
|
||||
func.__trigger_methods__ = [condition.__name__]
|
||||
func.__condition_type__ = "OR"
|
||||
@@ -231,7 +234,7 @@ def router(condition: str | dict | Callable) -> Callable:
|
||||
condition : Union[str, dict, Callable]
|
||||
Specifies when the router should execute. Can be:
|
||||
- str: Name of a method that triggers this router
|
||||
- dict: Result from or_() or and_(), including nested conditions
|
||||
- dict: Contains "type" ("AND"/"OR") and "methods" (list of triggers)
|
||||
- Callable: A method reference that triggers this router
|
||||
|
||||
Returns
|
||||
@@ -264,18 +267,13 @@ def router(condition: str | dict | Callable) -> Callable:
|
||||
if isinstance(condition, str):
|
||||
func.__trigger_methods__ = [condition]
|
||||
func.__condition_type__ = "OR"
|
||||
elif isinstance(condition, dict) and "type" in condition:
|
||||
if "conditions" in condition:
|
||||
func.__trigger_condition__ = condition
|
||||
func.__trigger_methods__ = _extract_all_methods(condition)
|
||||
func.__condition_type__ = condition["type"]
|
||||
elif "methods" in condition:
|
||||
func.__trigger_methods__ = condition["methods"]
|
||||
func.__condition_type__ = condition["type"]
|
||||
else:
|
||||
raise ValueError(
|
||||
"Condition dict must contain 'conditions' or 'methods'"
|
||||
)
|
||||
elif (
|
||||
isinstance(condition, dict)
|
||||
and "type" in condition
|
||||
and "methods" in condition
|
||||
):
|
||||
func.__trigger_methods__ = condition["methods"]
|
||||
func.__condition_type__ = condition["type"]
|
||||
elif callable(condition) and hasattr(condition, "__name__"):
|
||||
func.__trigger_methods__ = [condition.__name__]
|
||||
func.__condition_type__ = "OR"
|
||||
@@ -301,15 +299,14 @@ def or_(*conditions: str | dict | Callable) -> dict:
|
||||
*conditions : Union[str, dict, Callable]
|
||||
Variable number of conditions that can be:
|
||||
- str: Method names
|
||||
- dict: Existing condition dictionaries (nested conditions)
|
||||
- dict: Existing condition dictionaries
|
||||
- Callable: Method references
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
A condition dictionary with format:
|
||||
{"type": "OR", "conditions": list_of_conditions}
|
||||
where each condition can be a string (method name) or a nested dict
|
||||
{"type": "OR", "methods": list_of_method_names}
|
||||
|
||||
Raises
|
||||
------
|
||||
@@ -321,22 +318,18 @@ def or_(*conditions: str | dict | Callable) -> dict:
|
||||
>>> @listen(or_("success", "timeout"))
|
||||
>>> def handle_completion(self):
|
||||
... pass
|
||||
|
||||
>>> @listen(or_(and_("step1", "step2"), "step3"))
|
||||
>>> def handle_nested(self):
|
||||
... pass
|
||||
"""
|
||||
processed_conditions: list[str | dict[str, Any]] = []
|
||||
methods = []
|
||||
for condition in conditions:
|
||||
if isinstance(condition, dict):
|
||||
processed_conditions.append(condition)
|
||||
if isinstance(condition, dict) and "methods" in condition:
|
||||
methods.extend(condition["methods"])
|
||||
elif isinstance(condition, str):
|
||||
processed_conditions.append(condition)
|
||||
methods.append(condition)
|
||||
elif callable(condition):
|
||||
processed_conditions.append(getattr(condition, "__name__", repr(condition)))
|
||||
methods.append(getattr(condition, "__name__", repr(condition)))
|
||||
else:
|
||||
raise ValueError("Invalid condition in or_()")
|
||||
return {"type": "OR", "conditions": processed_conditions}
|
||||
return {"type": "OR", "methods": methods}
|
||||
|
||||
|
||||
def and_(*conditions: str | dict | Callable) -> dict:
|
||||
@@ -352,15 +345,14 @@ def and_(*conditions: str | dict | Callable) -> dict:
|
||||
*conditions : Union[str, dict, Callable]
|
||||
Variable number of conditions that can be:
|
||||
- str: Method names
|
||||
- dict: Existing condition dictionaries (nested conditions)
|
||||
- dict: Existing condition dictionaries
|
||||
- Callable: Method references
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
A condition dictionary with format:
|
||||
{"type": "AND", "conditions": list_of_conditions}
|
||||
where each condition can be a string (method name) or a nested dict
|
||||
{"type": "AND", "methods": list_of_method_names}
|
||||
|
||||
Raises
|
||||
------
|
||||
@@ -372,69 +364,18 @@ def and_(*conditions: str | dict | Callable) -> dict:
|
||||
>>> @listen(and_("validated", "processed"))
|
||||
>>> def handle_complete_data(self):
|
||||
... pass
|
||||
|
||||
>>> @listen(and_(or_("step1", "step2"), "step3"))
|
||||
>>> def handle_nested(self):
|
||||
... pass
|
||||
"""
|
||||
processed_conditions: list[str | dict[str, Any]] = []
|
||||
methods = []
|
||||
for condition in conditions:
|
||||
if isinstance(condition, dict):
|
||||
processed_conditions.append(condition)
|
||||
if isinstance(condition, dict) and "methods" in condition:
|
||||
methods.extend(condition["methods"])
|
||||
elif isinstance(condition, str):
|
||||
processed_conditions.append(condition)
|
||||
methods.append(condition)
|
||||
elif callable(condition):
|
||||
processed_conditions.append(getattr(condition, "__name__", repr(condition)))
|
||||
methods.append(getattr(condition, "__name__", repr(condition)))
|
||||
else:
|
||||
raise ValueError("Invalid condition in and_()")
|
||||
return {"type": "AND", "conditions": processed_conditions}
|
||||
|
||||
|
||||
def _normalize_condition(condition: str | dict | list) -> dict:
|
||||
"""Normalize a condition to standard format with 'conditions' key.
|
||||
|
||||
Args:
|
||||
condition: Can be a string (method name), dict (condition), or list
|
||||
|
||||
Returns:
|
||||
Normalized dict with 'type' and 'conditions' keys
|
||||
"""
|
||||
if isinstance(condition, str):
|
||||
return {"type": "OR", "conditions": [condition]}
|
||||
if isinstance(condition, dict):
|
||||
if "conditions" in condition:
|
||||
return condition
|
||||
if "methods" in condition:
|
||||
return {"type": condition["type"], "conditions": condition["methods"]}
|
||||
return condition
|
||||
if isinstance(condition, list):
|
||||
return {"type": "OR", "conditions": condition}
|
||||
return {"type": "OR", "conditions": [condition]}
|
||||
|
||||
|
||||
def _extract_all_methods(condition: str | dict | list) -> list[str]:
|
||||
"""Extract all method names from a condition (including nested).
|
||||
|
||||
Args:
|
||||
condition: Can be a string, dict, or list
|
||||
|
||||
Returns:
|
||||
List of all method names in the condition tree
|
||||
"""
|
||||
if isinstance(condition, str):
|
||||
return [condition]
|
||||
if isinstance(condition, dict):
|
||||
normalized = _normalize_condition(condition)
|
||||
methods = []
|
||||
for sub_cond in normalized.get("conditions", []):
|
||||
methods.extend(_extract_all_methods(sub_cond))
|
||||
return methods
|
||||
if isinstance(condition, list):
|
||||
methods = []
|
||||
for item in condition:
|
||||
methods.extend(_extract_all_methods(item))
|
||||
return methods
|
||||
return []
|
||||
return {"type": "AND", "methods": methods}
|
||||
|
||||
|
||||
class FlowMeta(type):
|
||||
@@ -462,10 +403,7 @@ class FlowMeta(type):
|
||||
if hasattr(attr_value, "__trigger_methods__"):
|
||||
methods = attr_value.__trigger_methods__
|
||||
condition_type = getattr(attr_value, "__condition_type__", "OR")
|
||||
if hasattr(attr_value, "__trigger_condition__"):
|
||||
listeners[attr_name] = attr_value.__trigger_condition__
|
||||
else:
|
||||
listeners[attr_name] = (condition_type, methods)
|
||||
listeners[attr_name] = (condition_type, methods)
|
||||
|
||||
if (
|
||||
hasattr(attr_value, "__is_router__")
|
||||
@@ -886,7 +824,6 @@ class Flow(Generic[T], metaclass=FlowMeta):
|
||||
# Clear completed methods and outputs for a fresh start
|
||||
self._completed_methods.clear()
|
||||
self._method_outputs.clear()
|
||||
self._pending_and_listeners.clear()
|
||||
else:
|
||||
# We're restoring from persistence, set the flag
|
||||
self._is_execution_resuming = True
|
||||
@@ -1178,16 +1115,10 @@ class Flow(Generic[T], metaclass=FlowMeta):
|
||||
for method_name in self._start_methods:
|
||||
# Check if this start method is triggered by the current trigger
|
||||
if method_name in self._listeners:
|
||||
condition_data = self._listeners[method_name]
|
||||
should_trigger = False
|
||||
if isinstance(condition_data, tuple):
|
||||
_, trigger_methods = condition_data
|
||||
should_trigger = current_trigger in trigger_methods
|
||||
elif isinstance(condition_data, dict):
|
||||
all_methods = _extract_all_methods(condition_data)
|
||||
should_trigger = current_trigger in all_methods
|
||||
|
||||
if should_trigger:
|
||||
_condition_type, trigger_methods = self._listeners[
|
||||
method_name
|
||||
]
|
||||
if current_trigger in trigger_methods:
|
||||
# Only execute if this is a cycle (method was already completed)
|
||||
if method_name in self._completed_methods:
|
||||
# For router-triggered start methods in cycles, temporarily clear resumption flag
|
||||
@@ -1197,51 +1128,6 @@ class Flow(Generic[T], metaclass=FlowMeta):
|
||||
await self._execute_start_method(method_name)
|
||||
self._is_execution_resuming = was_resuming
|
||||
|
||||
def _evaluate_condition(
|
||||
self, condition: str | dict, trigger_method: str, listener_name: str
|
||||
) -> bool:
|
||||
"""Recursively evaluate a condition (simple or nested).
|
||||
|
||||
Args:
|
||||
condition: Can be a string (method name) or dict (nested condition)
|
||||
trigger_method: The method that just completed
|
||||
listener_name: Name of the listener being evaluated
|
||||
|
||||
Returns:
|
||||
True if the condition is satisfied, False otherwise
|
||||
"""
|
||||
if isinstance(condition, str):
|
||||
return condition == trigger_method
|
||||
|
||||
if isinstance(condition, dict):
|
||||
normalized = _normalize_condition(condition)
|
||||
cond_type = normalized.get("type", "OR")
|
||||
sub_conditions = normalized.get("conditions", [])
|
||||
|
||||
if cond_type == "OR":
|
||||
return any(
|
||||
self._evaluate_condition(sub_cond, trigger_method, listener_name)
|
||||
for sub_cond in sub_conditions
|
||||
)
|
||||
|
||||
if cond_type == "AND":
|
||||
pending_key = f"{listener_name}:{id(condition)}"
|
||||
|
||||
if pending_key not in self._pending_and_listeners:
|
||||
all_methods = set(_extract_all_methods(condition))
|
||||
self._pending_and_listeners[pending_key] = all_methods
|
||||
|
||||
if trigger_method in self._pending_and_listeners[pending_key]:
|
||||
self._pending_and_listeners[pending_key].discard(trigger_method)
|
||||
|
||||
if not self._pending_and_listeners[pending_key]:
|
||||
self._pending_and_listeners.pop(pending_key, None)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
def _find_triggered_methods(
|
||||
self, trigger_method: str, router_only: bool
|
||||
) -> list[str]:
|
||||
@@ -1249,7 +1135,7 @@ class Flow(Generic[T], metaclass=FlowMeta):
|
||||
Finds all methods that should be triggered based on conditions.
|
||||
|
||||
This internal method evaluates both OR and AND conditions to determine
|
||||
which methods should be executed next in the flow. Supports nested conditions.
|
||||
which methods should be executed next in the flow.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
@@ -1266,13 +1152,14 @@ class Flow(Generic[T], metaclass=FlowMeta):
|
||||
|
||||
Notes
|
||||
-----
|
||||
- Handles both OR and AND conditions, including nested combinations
|
||||
- Handles both OR and AND conditions:
|
||||
* OR: Triggers if any condition is met
|
||||
* AND: Triggers only when all conditions are met
|
||||
- Maintains state for AND conditions using _pending_and_listeners
|
||||
- Separates router and normal listener evaluation
|
||||
"""
|
||||
triggered = []
|
||||
|
||||
for listener_name, condition_data in self._listeners.items():
|
||||
for listener_name, (condition_type, methods) in self._listeners.items():
|
||||
is_router = listener_name in self._routers
|
||||
|
||||
if router_only != is_router:
|
||||
@@ -1281,29 +1168,23 @@ class Flow(Generic[T], metaclass=FlowMeta):
|
||||
if not router_only and listener_name in self._start_methods:
|
||||
continue
|
||||
|
||||
if isinstance(condition_data, tuple):
|
||||
condition_type, methods = condition_data
|
||||
|
||||
if condition_type == "OR":
|
||||
if trigger_method in methods:
|
||||
triggered.append(listener_name)
|
||||
elif condition_type == "AND":
|
||||
if listener_name not in self._pending_and_listeners:
|
||||
self._pending_and_listeners[listener_name] = set(methods)
|
||||
if trigger_method in self._pending_and_listeners[listener_name]:
|
||||
self._pending_and_listeners[listener_name].discard(
|
||||
trigger_method
|
||||
)
|
||||
|
||||
if not self._pending_and_listeners[listener_name]:
|
||||
triggered.append(listener_name)
|
||||
self._pending_and_listeners.pop(listener_name, None)
|
||||
|
||||
elif isinstance(condition_data, dict):
|
||||
if self._evaluate_condition(
|
||||
condition_data, trigger_method, listener_name
|
||||
):
|
||||
if condition_type == "OR":
|
||||
# If the trigger_method matches any in methods, run this
|
||||
if trigger_method in methods:
|
||||
triggered.append(listener_name)
|
||||
elif condition_type == "AND":
|
||||
# Initialize pending methods for this listener if not already done
|
||||
if listener_name not in self._pending_and_listeners:
|
||||
self._pending_and_listeners[listener_name] = set(methods)
|
||||
# Remove the trigger method from pending methods
|
||||
if trigger_method in self._pending_and_listeners[listener_name]:
|
||||
self._pending_and_listeners[listener_name].discard(trigger_method)
|
||||
|
||||
if not self._pending_and_listeners[listener_name]:
|
||||
# All required methods have been executed
|
||||
triggered.append(listener_name)
|
||||
# Reset pending methods for this listener
|
||||
self._pending_and_listeners.pop(listener_name, None)
|
||||
|
||||
return triggered
|
||||
|
||||
@@ -1366,7 +1247,7 @@ class Flow(Generic[T], metaclass=FlowMeta):
|
||||
raise
|
||||
|
||||
def _log_flow_event(
|
||||
self, message: str, color: PrinterColor | None = "yellow", level: str = "info"
|
||||
self, message: str, color: str = "yellow", level: str = "info"
|
||||
) -> None:
|
||||
"""Centralized logging method for flow events.
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ except ImportError:
|
||||
|
||||
|
||||
load_dotenv()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if LITELLM_AVAILABLE:
|
||||
litellm.suppress_debug_info = True
|
||||
|
||||
@@ -273,17 +273,6 @@ LLM_CONTEXT_WINDOW_SIZES: Final[dict[str, int]] = {
|
||||
|
||||
DEFAULT_CONTEXT_WINDOW_SIZE: Final[int] = 8192
|
||||
CONTEXT_WINDOW_USAGE_RATIO: Final[float] = 0.85
|
||||
SUPPORTED_NATIVE_PROVIDERS: Final[list[str]] = [
|
||||
"openai",
|
||||
"anthropic",
|
||||
"claude",
|
||||
"azure",
|
||||
"azure_openai",
|
||||
"google",
|
||||
"gemini",
|
||||
"bedrock",
|
||||
"aws",
|
||||
]
|
||||
|
||||
|
||||
class Delta(TypedDict):
|
||||
@@ -317,17 +306,24 @@ class LLM(BaseLLM):
|
||||
provider = model.partition("/")[0] if "/" in model else "openai"
|
||||
|
||||
native_class = cls._get_native_provider(provider)
|
||||
if native_class and not is_litellm and provider in SUPPORTED_NATIVE_PROVIDERS:
|
||||
if native_class and not is_litellm:
|
||||
try:
|
||||
model_string = model.partition("/")[2] if "/" in model else model
|
||||
return native_class(model=model_string, provider=provider, **kwargs)
|
||||
except Exception as e:
|
||||
raise ImportError(f"Error importing native provider: {e}") from e
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(
|
||||
f"Native SDK failed for {provider}: {e}, falling back to LiteLLM"
|
||||
)
|
||||
|
||||
# FALLBACK to LiteLLM
|
||||
if not LITELLM_AVAILABLE:
|
||||
logger.error("LiteLLM is not available, falling back to LiteLLM")
|
||||
raise ImportError("Fallback to LiteLLM is not available") from None
|
||||
raise ImportError(
|
||||
"Please install the required dependencies:\n"
|
||||
"- For LiteLLM: uv add litellm"
|
||||
)
|
||||
|
||||
instance = object.__new__(cls)
|
||||
super(LLM, instance).__init__(model=model, is_litellm=True, **kwargs)
|
||||
@@ -338,31 +334,46 @@ class LLM(BaseLLM):
|
||||
def _get_native_provider(cls, provider: str) -> type | None:
|
||||
"""Get native provider class if available."""
|
||||
if provider == "openai":
|
||||
from crewai.llms.providers.openai.completion import OpenAICompletion
|
||||
try:
|
||||
from crewai.llms.providers.openai.completion import OpenAICompletion
|
||||
|
||||
return OpenAICompletion
|
||||
return OpenAICompletion
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
if provider == "anthropic" or provider == "claude":
|
||||
from crewai.llms.providers.anthropic.completion import (
|
||||
AnthropicCompletion,
|
||||
)
|
||||
elif provider == "anthropic" or provider == "claude":
|
||||
try:
|
||||
from crewai.llms.providers.anthropic.completion import (
|
||||
AnthropicCompletion,
|
||||
)
|
||||
|
||||
return AnthropicCompletion
|
||||
return AnthropicCompletion
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
if provider == "azure" or provider == "azure_openai":
|
||||
from crewai.llms.providers.azure.completion import AzureCompletion
|
||||
elif provider == "azure":
|
||||
try:
|
||||
from crewai.llms.providers.azure.completion import AzureCompletion
|
||||
|
||||
return AzureCompletion
|
||||
return AzureCompletion
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
if provider == "google" or provider == "gemini":
|
||||
from crewai.llms.providers.gemini.completion import GeminiCompletion
|
||||
elif provider == "google" or provider == "gemini":
|
||||
try:
|
||||
from crewai.llms.providers.gemini.completion import GeminiCompletion
|
||||
|
||||
return GeminiCompletion
|
||||
return GeminiCompletion
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
if provider == "bedrock":
|
||||
from crewai.llms.providers.bedrock.completion import BedrockCompletion
|
||||
elif provider == "bedrock":
|
||||
try:
|
||||
from crewai.llms.providers.bedrock.completion import BedrockCompletion
|
||||
|
||||
return BedrockCompletion
|
||||
return BedrockCompletion
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ try:
|
||||
from anthropic.types.tool_use_block import ToolUseBlock
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
'Anthropic native provider not available, to install: uv add "crewai[anthropic]"'
|
||||
"Anthropic native provider not available, to install: `uv add anthropic`"
|
||||
) from None
|
||||
|
||||
|
||||
|
||||
@@ -10,20 +10,20 @@ from crewai.utilities.exceptions.context_window_exceeding_exception import (
|
||||
|
||||
|
||||
try:
|
||||
from azure.ai.inference import ChatCompletionsClient
|
||||
from azure.ai.inference.models import (
|
||||
from azure.ai.inference import ChatCompletionsClient # type: ignore
|
||||
from azure.ai.inference.models import ( # type: ignore
|
||||
ChatCompletions,
|
||||
ChatCompletionsToolCall,
|
||||
StreamingChatCompletionsUpdate,
|
||||
)
|
||||
from azure.core.credentials import AzureKeyCredential
|
||||
from azure.core.exceptions import HttpResponseError
|
||||
from azure.core.credentials import AzureKeyCredential # type: ignore
|
||||
from azure.core.exceptions import HttpResponseError # type: ignore
|
||||
from crewai.events.types.llm_events import LLMCallType
|
||||
from crewai.llms.base_llm import BaseLLM
|
||||
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
'Azure AI Inference native provider not available, to install: uv add "crewai[azure-ai-inference]"'
|
||||
"Azure AI Inference native provider not available, to install: `uv add azure-ai-inference`"
|
||||
) from None
|
||||
|
||||
|
||||
@@ -80,9 +80,7 @@ class AzureCompletion(BaseLLM):
|
||||
or os.getenv("AZURE_OPENAI_ENDPOINT")
|
||||
or os.getenv("AZURE_API_BASE")
|
||||
)
|
||||
self.api_version = api_version or os.getenv("AZURE_API_VERSION") or "2024-06-01"
|
||||
self.timeout = timeout
|
||||
self.max_retries = max_retries
|
||||
self.api_version = api_version or os.getenv("AZURE_API_VERSION") or "2024-02-01"
|
||||
|
||||
if not self.api_key:
|
||||
raise ValueError(
|
||||
@@ -93,20 +91,10 @@ class AzureCompletion(BaseLLM):
|
||||
"Azure endpoint is required. Set AZURE_ENDPOINT environment variable or pass endpoint parameter."
|
||||
)
|
||||
|
||||
# Validate and potentially fix Azure OpenAI endpoint URL
|
||||
self.endpoint = self._validate_and_fix_endpoint(self.endpoint, model)
|
||||
|
||||
# Build client kwargs
|
||||
client_kwargs = {
|
||||
"endpoint": self.endpoint,
|
||||
"credential": AzureKeyCredential(self.api_key),
|
||||
}
|
||||
|
||||
# Add api_version if specified (primarily for Azure OpenAI endpoints)
|
||||
if self.api_version:
|
||||
client_kwargs["api_version"] = self.api_version
|
||||
|
||||
self.client = ChatCompletionsClient(**client_kwargs)
|
||||
self.client = ChatCompletionsClient(
|
||||
endpoint=self.endpoint,
|
||||
credential=AzureKeyCredential(self.api_key),
|
||||
)
|
||||
|
||||
self.top_p = top_p
|
||||
self.frequency_penalty = frequency_penalty
|
||||
@@ -118,34 +106,6 @@ class AzureCompletion(BaseLLM):
|
||||
prefix in model.lower() for prefix in ["gpt-", "o1-", "text-"]
|
||||
)
|
||||
|
||||
self.is_azure_openai_endpoint = (
|
||||
"openai.azure.com" in self.endpoint
|
||||
and "/openai/deployments/" in self.endpoint
|
||||
)
|
||||
|
||||
def _validate_and_fix_endpoint(self, endpoint: str, model: str) -> str:
|
||||
"""Validate and fix Azure endpoint URL format.
|
||||
|
||||
Azure OpenAI endpoints should be in the format:
|
||||
https://<resource-name>.openai.azure.com/openai/deployments/<deployment-name>
|
||||
|
||||
Args:
|
||||
endpoint: The endpoint URL
|
||||
model: The model/deployment name
|
||||
|
||||
Returns:
|
||||
Validated and potentially corrected endpoint URL
|
||||
"""
|
||||
if "openai.azure.com" in endpoint and "/openai/deployments/" not in endpoint:
|
||||
endpoint = endpoint.rstrip("/")
|
||||
|
||||
if not endpoint.endswith("/openai/deployments"):
|
||||
deployment_name = model.replace("azure/", "")
|
||||
endpoint = f"{endpoint}/openai/deployments/{deployment_name}"
|
||||
logging.info(f"Constructed Azure OpenAI endpoint URL: {endpoint}")
|
||||
|
||||
return endpoint
|
||||
|
||||
def call(
|
||||
self,
|
||||
messages: str | list[dict[str, str]],
|
||||
@@ -198,17 +158,7 @@ class AzureCompletion(BaseLLM):
|
||||
)
|
||||
|
||||
except HttpResponseError as e:
|
||||
if e.status_code == 401:
|
||||
error_msg = "Azure authentication failed. Check your API key."
|
||||
elif e.status_code == 404:
|
||||
error_msg = (
|
||||
f"Azure endpoint not found. Check endpoint URL: {self.endpoint}"
|
||||
)
|
||||
elif e.status_code == 429:
|
||||
error_msg = "Azure API rate limit exceeded. Please retry later."
|
||||
else:
|
||||
error_msg = f"Azure API HTTP error: {e.status_code} - {e.message}"
|
||||
|
||||
error_msg = f"Azure API HTTP error: {e.status_code} - {e.message}"
|
||||
logging.error(error_msg)
|
||||
self._emit_call_failed_event(
|
||||
error=error_msg, from_task=from_task, from_agent=from_agent
|
||||
@@ -237,15 +187,11 @@ class AzureCompletion(BaseLLM):
|
||||
Parameters dictionary for Azure API
|
||||
"""
|
||||
params = {
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"stream": self.stream,
|
||||
}
|
||||
|
||||
# Only include model parameter for non-Azure OpenAI endpoints
|
||||
# Azure OpenAI endpoints have the deployment name in the URL
|
||||
if not self.is_azure_openai_endpoint:
|
||||
params["model"] = self.model
|
||||
|
||||
# Add optional parameters if set
|
||||
if self.temperature is not None:
|
||||
params["temperature"] = self.temperature
|
||||
@@ -304,7 +250,7 @@ class AzureCompletion(BaseLLM):
|
||||
messages: Input messages
|
||||
|
||||
Returns:
|
||||
List of dict objects with 'role' and 'content' keys
|
||||
List of dict objects
|
||||
"""
|
||||
# Use base class formatting first
|
||||
base_formatted = super()._format_messages(messages)
|
||||
@@ -312,11 +258,18 @@ class AzureCompletion(BaseLLM):
|
||||
azure_messages = []
|
||||
|
||||
for message in base_formatted:
|
||||
role = message.get("role", "user") # Default to user if no role
|
||||
role = message.get("role")
|
||||
content = message.get("content", "")
|
||||
|
||||
# Azure AI Inference requires both 'role' and 'content'
|
||||
azure_messages.append({"role": role, "content": content})
|
||||
if role == "system":
|
||||
azure_messages.append(dict(content=content))
|
||||
elif role == "user":
|
||||
azure_messages.append(dict(content=content))
|
||||
elif role == "assistant":
|
||||
azure_messages.append(dict(content=content))
|
||||
else:
|
||||
# Default to user message for unknown roles
|
||||
azure_messages.append(dict(content=content))
|
||||
|
||||
return azure_messages
|
||||
|
||||
@@ -386,13 +339,6 @@ class AzureCompletion(BaseLLM):
|
||||
logging.error(f"Context window exceeded: {e}")
|
||||
raise LLMContextLengthExceededError(str(e)) from e
|
||||
|
||||
error_msg = f"Azure API call failed: {e!s}"
|
||||
logging.error(error_msg)
|
||||
self._emit_call_failed_event(
|
||||
error=error_msg, from_task=from_task, from_agent=from_agent
|
||||
)
|
||||
raise e
|
||||
|
||||
return content
|
||||
|
||||
def _handle_streaming_completion(
|
||||
@@ -508,9 +454,7 @@ class AzureCompletion(BaseLLM):
|
||||
}
|
||||
|
||||
# Find the best match for the model name
|
||||
for model_prefix, size in sorted(
|
||||
context_windows.items(), key=lambda x: len(x[0]), reverse=True
|
||||
):
|
||||
for model_prefix, size in context_windows.items():
|
||||
if self.model.startswith(model_prefix):
|
||||
return int(size * CONTEXT_WINDOW_USAGE_RATIO)
|
||||
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
import logging
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Any, TypedDict, cast
|
||||
|
||||
from typing_extensions import Required
|
||||
from typing import Any
|
||||
|
||||
from crewai.events.types.llm_events import LLMCallType
|
||||
from crewai.llms.base_llm import BaseLLM
|
||||
@@ -15,128 +11,21 @@ from crewai.utilities.exceptions.context_window_exceeding_exception import (
|
||||
)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mypy_boto3_bedrock_runtime.type_defs import (
|
||||
GuardrailConfigurationTypeDef,
|
||||
GuardrailStreamConfigurationTypeDef,
|
||||
InferenceConfigurationTypeDef,
|
||||
MessageOutputTypeDef,
|
||||
MessageTypeDef,
|
||||
SystemContentBlockTypeDef,
|
||||
TokenUsageTypeDef,
|
||||
ToolConfigurationTypeDef,
|
||||
ToolTypeDef,
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
from boto3.session import Session
|
||||
from botocore.config import Config
|
||||
from botocore.exceptions import BotoCoreError, ClientError
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
'AWS Bedrock native provider not available, to install: uv add "crewai[bedrock]"'
|
||||
"AWS Bedrock native provider not available, to install: `uv add boto3`"
|
||||
) from None
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
class EnhancedInferenceConfigurationTypeDef(
|
||||
InferenceConfigurationTypeDef, total=False
|
||||
):
|
||||
"""Extended InferenceConfigurationTypeDef with topK support.
|
||||
|
||||
AWS Bedrock supports topK for Claude models, but it's not in the boto3 type stubs.
|
||||
This extends the base type to include topK while maintaining all other fields.
|
||||
"""
|
||||
|
||||
topK: int # noqa: N815 - AWS API uses topK naming
|
||||
|
||||
else:
|
||||
|
||||
class EnhancedInferenceConfigurationTypeDef(TypedDict, total=False):
|
||||
"""Extended InferenceConfigurationTypeDef with topK support.
|
||||
|
||||
AWS Bedrock supports topK for Claude models, but it's not in the boto3 type stubs.
|
||||
This extends the base type to include topK while maintaining all other fields.
|
||||
"""
|
||||
|
||||
maxTokens: int
|
||||
temperature: float
|
||||
topP: float # noqa: N815 - AWS API uses topP naming
|
||||
stopSequences: list[str]
|
||||
topK: int # noqa: N815 - AWS API uses topK naming
|
||||
|
||||
|
||||
class ToolInputSchema(TypedDict):
|
||||
"""Type definition for tool input schema in Converse API."""
|
||||
|
||||
json: dict[str, Any]
|
||||
|
||||
|
||||
class ToolSpec(TypedDict, total=False):
|
||||
"""Type definition for tool specification in Converse API."""
|
||||
|
||||
name: Required[str]
|
||||
description: Required[str]
|
||||
inputSchema: ToolInputSchema
|
||||
|
||||
|
||||
class ConverseToolTypeDef(TypedDict):
|
||||
"""Type definition for a Converse API tool."""
|
||||
|
||||
toolSpec: ToolSpec
|
||||
|
||||
|
||||
class BedrockConverseRequestBody(TypedDict, total=False):
|
||||
"""Type definition for AWS Bedrock Converse API request body.
|
||||
|
||||
Based on AWS Bedrock Converse API specification.
|
||||
"""
|
||||
|
||||
inferenceConfig: Required[EnhancedInferenceConfigurationTypeDef]
|
||||
system: list[SystemContentBlockTypeDef]
|
||||
toolConfig: ToolConfigurationTypeDef
|
||||
guardrailConfig: GuardrailConfigurationTypeDef
|
||||
additionalModelRequestFields: dict[str, Any]
|
||||
additionalModelResponseFieldPaths: list[str]
|
||||
|
||||
|
||||
class BedrockConverseStreamRequestBody(TypedDict, total=False):
|
||||
"""Type definition for AWS Bedrock Converse Stream API request body.
|
||||
|
||||
Based on AWS Bedrock Converse Stream API specification.
|
||||
"""
|
||||
|
||||
inferenceConfig: Required[EnhancedInferenceConfigurationTypeDef]
|
||||
system: list[SystemContentBlockTypeDef]
|
||||
toolConfig: ToolConfigurationTypeDef
|
||||
guardrailConfig: GuardrailStreamConfigurationTypeDef
|
||||
additionalModelRequestFields: dict[str, Any]
|
||||
additionalModelResponseFieldPaths: list[str]
|
||||
|
||||
|
||||
class BedrockCompletion(BaseLLM):
|
||||
"""AWS Bedrock native completion implementation using the Converse API.
|
||||
|
||||
This class provides direct integration with AWS Bedrock using the modern
|
||||
Converse API, which provides a unified interface across all Bedrock models.
|
||||
|
||||
Features:
|
||||
- Full tool calling support with proper conversation continuation
|
||||
- Streaming and non-streaming responses with comprehensive event handling
|
||||
- Guardrail configuration for content filtering
|
||||
- Model-specific parameters via additionalModelRequestFields
|
||||
- Custom response field extraction
|
||||
- Proper error handling for all AWS exception types
|
||||
- Token usage tracking and stop reason logging
|
||||
- Support for both text and tool use content blocks
|
||||
|
||||
The implementation follows AWS Bedrock Converse API best practices including:
|
||||
- Proper tool use ID tracking for multi-turn tool conversations
|
||||
- Complete streaming event handling (messageStart, contentBlockStart, etc.)
|
||||
- Response metadata and trace information capture
|
||||
- Model-specific conversation format handling (e.g., Cohere requirements)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -152,30 +41,9 @@ class BedrockCompletion(BaseLLM):
|
||||
top_k: int | None = None,
|
||||
stop_sequences: Sequence[str] | None = None,
|
||||
stream: bool = False,
|
||||
guardrail_config: dict[str, Any] | None = None,
|
||||
additional_model_request_fields: dict[str, Any] | None = None,
|
||||
additional_model_response_field_paths: list[str] | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize AWS Bedrock completion client.
|
||||
|
||||
Args:
|
||||
model: The Bedrock model ID to use
|
||||
aws_access_key_id: AWS access key (defaults to environment variable)
|
||||
aws_secret_access_key: AWS secret key (defaults to environment variable)
|
||||
aws_session_token: AWS session token for temporary credentials
|
||||
region_name: AWS region name
|
||||
temperature: Sampling temperature for response generation
|
||||
max_tokens: Maximum tokens to generate
|
||||
top_p: Nucleus sampling parameter
|
||||
top_k: Top-k sampling parameter (Claude models only)
|
||||
stop_sequences: List of sequences that stop generation
|
||||
stream: Whether to use streaming responses
|
||||
guardrail_config: Guardrail configuration for content filtering
|
||||
additional_model_request_fields: Model-specific request parameters
|
||||
additional_model_response_field_paths: Custom response field paths
|
||||
**kwargs: Additional parameters
|
||||
"""
|
||||
"""Initialize AWS Bedrock completion client."""
|
||||
# Extract provider from kwargs to avoid duplicate argument
|
||||
kwargs.pop("provider", None)
|
||||
|
||||
@@ -198,6 +66,7 @@ class BedrockCompletion(BaseLLM):
|
||||
|
||||
# Configure client with timeouts and retries following AWS best practices
|
||||
config = Config(
|
||||
connect_timeout=60,
|
||||
read_timeout=300,
|
||||
retries={
|
||||
"max_attempts": 3,
|
||||
@@ -216,13 +85,6 @@ class BedrockCompletion(BaseLLM):
|
||||
self.stream = stream
|
||||
self.stop_sequences = stop_sequences or []
|
||||
|
||||
# Store advanced features (optional)
|
||||
self.guardrail_config = guardrail_config
|
||||
self.additional_model_request_fields = additional_model_request_fields
|
||||
self.additional_model_response_field_paths = (
|
||||
additional_model_response_field_paths
|
||||
)
|
||||
|
||||
# Model-specific settings
|
||||
self.is_claude_model = "claude" in model.lower()
|
||||
self.supports_tools = True # Converse API supports tools for most models
|
||||
@@ -234,7 +96,7 @@ class BedrockCompletion(BaseLLM):
|
||||
def call(
|
||||
self,
|
||||
messages: str | list[dict[str, str]],
|
||||
tools: list[dict[Any, Any]] | None = None,
|
||||
tools: Sequence[Mapping[str, Any]] | None = None,
|
||||
callbacks: list[Any] | None = None,
|
||||
available_functions: dict[str, Any] | None = None,
|
||||
from_task: Any | None = None,
|
||||
@@ -257,45 +119,24 @@ class BedrockCompletion(BaseLLM):
|
||||
messages
|
||||
)
|
||||
|
||||
# Prepare tool configuration
|
||||
tool_config = None
|
||||
if tools:
|
||||
tool_config = {"tools": self._format_tools_for_converse(tools)}
|
||||
|
||||
# Prepare request body
|
||||
body: BedrockConverseRequestBody = {
|
||||
body = {
|
||||
"inferenceConfig": self._get_inference_config(),
|
||||
}
|
||||
|
||||
# Add system message if present
|
||||
if system_message:
|
||||
body["system"] = cast(
|
||||
"list[SystemContentBlockTypeDef]",
|
||||
cast(object, [{"text": system_message}]),
|
||||
)
|
||||
body["system"] = [{"text": system_message}]
|
||||
|
||||
# Add tool config if present
|
||||
if tools:
|
||||
tool_config: ToolConfigurationTypeDef = {
|
||||
"tools": cast(
|
||||
"Sequence[ToolTypeDef]",
|
||||
cast(object, self._format_tools_for_converse(tools)),
|
||||
)
|
||||
}
|
||||
if tool_config:
|
||||
body["toolConfig"] = tool_config
|
||||
|
||||
# Add optional advanced features if configured
|
||||
if self.guardrail_config:
|
||||
guardrail_config: GuardrailConfigurationTypeDef = cast(
|
||||
"GuardrailConfigurationTypeDef", cast(object, self.guardrail_config)
|
||||
)
|
||||
body["guardrailConfig"] = guardrail_config
|
||||
|
||||
if self.additional_model_request_fields:
|
||||
body["additionalModelRequestFields"] = (
|
||||
self.additional_model_request_fields
|
||||
)
|
||||
|
||||
if self.additional_model_response_field_paths:
|
||||
body["additionalModelResponseFieldPaths"] = (
|
||||
self.additional_model_response_field_paths
|
||||
)
|
||||
|
||||
if self.stream:
|
||||
return self._handle_streaming_converse(
|
||||
formatted_messages, body, available_functions, from_task, from_agent
|
||||
@@ -320,7 +161,7 @@ class BedrockCompletion(BaseLLM):
|
||||
def _handle_converse(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
body: BedrockConverseRequestBody,
|
||||
body: dict[str, Any],
|
||||
available_functions: Mapping[str, Any] | None = None,
|
||||
from_task: Any | None = None,
|
||||
from_agent: Any | None = None,
|
||||
@@ -342,26 +183,13 @@ class BedrockCompletion(BaseLLM):
|
||||
|
||||
# Call Bedrock Converse API with proper error handling
|
||||
response = self.client.converse(
|
||||
modelId=self.model_id,
|
||||
messages=cast(
|
||||
"Sequence[MessageTypeDef | MessageOutputTypeDef]",
|
||||
cast(object, messages),
|
||||
),
|
||||
**body,
|
||||
modelId=self.model_id, messages=messages, **body
|
||||
)
|
||||
|
||||
# Track token usage according to AWS response format
|
||||
if "usage" in response:
|
||||
self._track_token_usage_internal(response["usage"])
|
||||
|
||||
stop_reason = response.get("stopReason")
|
||||
if stop_reason:
|
||||
logging.debug(f"Response stop reason: {stop_reason}")
|
||||
if stop_reason == "max_tokens":
|
||||
logging.warning("Response truncated due to max_tokens limit")
|
||||
elif stop_reason == "content_filtered":
|
||||
logging.warning("Response was filtered due to content policy")
|
||||
|
||||
# Extract content following AWS response structure
|
||||
output = response.get("output", {})
|
||||
message = output.get("message", {})
|
||||
@@ -373,59 +201,28 @@ class BedrockCompletion(BaseLLM):
|
||||
"I apologize, but I received an empty response. Please try again."
|
||||
)
|
||||
|
||||
# Process content blocks and handle tool use correctly
|
||||
# Extract text content from response
|
||||
text_content = ""
|
||||
|
||||
for content_block in content:
|
||||
# Handle text content
|
||||
# Handle different content block types as per AWS documentation
|
||||
if "text" in content_block:
|
||||
text_content += content_block["text"]
|
||||
elif content_block.get("type") == "toolUse" and available_functions:
|
||||
# Handle tool use according to AWS format
|
||||
tool_use = content_block["toolUse"]
|
||||
function_name = tool_use.get("name")
|
||||
function_args = tool_use.get("input", {})
|
||||
|
||||
# Handle tool use - corrected structure according to AWS API docs
|
||||
elif "toolUse" in content_block and available_functions:
|
||||
tool_use_block = content_block["toolUse"]
|
||||
tool_use_id = tool_use_block.get("toolUseId")
|
||||
function_name = tool_use_block["name"]
|
||||
function_args = tool_use_block.get("input", {})
|
||||
|
||||
logging.debug(
|
||||
f"Tool use requested: {function_name} with ID {tool_use_id}"
|
||||
)
|
||||
|
||||
# Execute the tool
|
||||
tool_result = self._handle_tool_execution(
|
||||
result = self._handle_tool_execution(
|
||||
function_name=function_name,
|
||||
function_args=function_args,
|
||||
available_functions=dict(available_functions),
|
||||
available_functions=available_functions,
|
||||
from_task=from_task,
|
||||
from_agent=from_agent,
|
||||
)
|
||||
|
||||
if tool_result is not None:
|
||||
messages.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{"toolUse": tool_use_block}],
|
||||
}
|
||||
)
|
||||
|
||||
messages.append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"toolResult": {
|
||||
"toolUseId": tool_use_id,
|
||||
"content": [{"text": str(tool_result)}],
|
||||
}
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
return self._handle_converse(
|
||||
messages, body, available_functions, from_task, from_agent
|
||||
)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
# Apply stop sequences if configured
|
||||
text_content = self._apply_stop_words(text_content)
|
||||
@@ -501,43 +298,23 @@ class BedrockCompletion(BaseLLM):
|
||||
def _handle_streaming_converse(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
body: BedrockConverseRequestBody,
|
||||
body: dict[str, Any],
|
||||
available_functions: dict[str, Any] | None = None,
|
||||
from_task: Any | None = None,
|
||||
from_agent: Any | None = None,
|
||||
) -> str:
|
||||
"""Handle streaming converse API call with comprehensive event handling."""
|
||||
"""Handle streaming converse API call."""
|
||||
full_response = ""
|
||||
current_tool_use = None
|
||||
tool_use_id = None
|
||||
|
||||
try:
|
||||
response = self.client.converse_stream(
|
||||
modelId=self.model_id,
|
||||
messages=cast(
|
||||
"Sequence[MessageTypeDef | MessageOutputTypeDef]",
|
||||
cast(object, messages),
|
||||
),
|
||||
**body, # type: ignore[arg-type]
|
||||
modelId=self.model_id, messages=messages, **body
|
||||
)
|
||||
|
||||
stream = response.get("stream")
|
||||
if stream:
|
||||
for event in stream:
|
||||
if "messageStart" in event:
|
||||
role = event["messageStart"].get("role")
|
||||
logging.debug(f"Streaming message started with role: {role}")
|
||||
|
||||
elif "contentBlockStart" in event:
|
||||
start = event["contentBlockStart"].get("start", {})
|
||||
if "toolUse" in start:
|
||||
current_tool_use = start["toolUse"]
|
||||
tool_use_id = current_tool_use.get("toolUseId")
|
||||
logging.debug(
|
||||
f"Tool use started in stream: {current_tool_use.get('name')} (ID: {tool_use_id})"
|
||||
)
|
||||
|
||||
elif "contentBlockDelta" in event:
|
||||
if "contentBlockDelta" in event:
|
||||
delta = event["contentBlockDelta"]["delta"]
|
||||
if "text" in delta:
|
||||
text_chunk = delta["text"]
|
||||
@@ -548,93 +325,10 @@ class BedrockCompletion(BaseLLM):
|
||||
from_task=from_task,
|
||||
from_agent=from_agent,
|
||||
)
|
||||
elif "toolUse" in delta and current_tool_use:
|
||||
tool_input = delta["toolUse"].get("input", "")
|
||||
if tool_input:
|
||||
logging.debug(f"Tool input delta: {tool_input}")
|
||||
|
||||
# Content block stop - end of a content block
|
||||
elif "contentBlockStop" in event:
|
||||
logging.debug("Content block stopped in stream")
|
||||
# If we were accumulating a tool use, it's now complete
|
||||
if current_tool_use and available_functions:
|
||||
function_name = current_tool_use["name"]
|
||||
function_args = cast(
|
||||
dict[str, Any], current_tool_use.get("input", {})
|
||||
)
|
||||
|
||||
# Execute tool
|
||||
tool_result = self._handle_tool_execution(
|
||||
function_name=function_name,
|
||||
function_args=function_args,
|
||||
available_functions=available_functions,
|
||||
from_task=from_task,
|
||||
from_agent=from_agent,
|
||||
)
|
||||
|
||||
if tool_result is not None and tool_use_id:
|
||||
# Continue conversation with tool result
|
||||
messages.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{"toolUse": current_tool_use}],
|
||||
}
|
||||
)
|
||||
|
||||
messages.append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"toolResult": {
|
||||
"toolUseId": tool_use_id,
|
||||
"content": [
|
||||
{"text": str(tool_result)}
|
||||
],
|
||||
}
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
# Recursive call - note this switches to non-streaming
|
||||
return self._handle_converse(
|
||||
messages,
|
||||
body,
|
||||
available_functions,
|
||||
from_task,
|
||||
from_agent,
|
||||
)
|
||||
|
||||
current_tool_use = None
|
||||
tool_use_id = None
|
||||
|
||||
# Message stop - end of entire message
|
||||
elif "messageStop" in event:
|
||||
stop_reason = event["messageStop"].get("stopReason")
|
||||
logging.debug(f"Streaming message stopped: {stop_reason}")
|
||||
if stop_reason == "max_tokens":
|
||||
logging.warning(
|
||||
"Streaming response truncated due to max_tokens"
|
||||
)
|
||||
elif stop_reason == "content_filtered":
|
||||
logging.warning(
|
||||
"Streaming response filtered due to content policy"
|
||||
)
|
||||
# Handle end of message
|
||||
break
|
||||
|
||||
# Metadata - contains usage information and trace details
|
||||
elif "metadata" in event:
|
||||
metadata = event["metadata"]
|
||||
if "usage" in metadata:
|
||||
usage_metrics = metadata["usage"]
|
||||
self._track_token_usage_internal(usage_metrics)
|
||||
logging.debug(f"Token usage: {usage_metrics}")
|
||||
if "trace" in metadata:
|
||||
logging.debug(
|
||||
f"Trace information available: {metadata['trace']}"
|
||||
)
|
||||
|
||||
except ClientError as e:
|
||||
error_msg = self._handle_client_error(e)
|
||||
raise RuntimeError(error_msg) from e
|
||||
@@ -736,27 +430,25 @@ class BedrockCompletion(BaseLLM):
|
||||
|
||||
return converse_messages, system_message
|
||||
|
||||
@staticmethod
|
||||
def _format_tools_for_converse(tools: list[dict]) -> list[ConverseToolTypeDef]:
|
||||
def _format_tools_for_converse(self, tools: list[dict]) -> list[dict]:
|
||||
"""Convert CrewAI tools to Converse API format following AWS specification."""
|
||||
from crewai.llms.providers.utils.common import safe_tool_conversion
|
||||
|
||||
converse_tools: list[ConverseToolTypeDef] = []
|
||||
converse_tools = []
|
||||
|
||||
for tool in tools:
|
||||
try:
|
||||
name, description, parameters = safe_tool_conversion(tool, "Bedrock")
|
||||
|
||||
tool_spec: ToolSpec = {
|
||||
"name": name,
|
||||
"description": description,
|
||||
converse_tool = {
|
||||
"toolSpec": {
|
||||
"name": name,
|
||||
"description": description,
|
||||
}
|
||||
}
|
||||
|
||||
if parameters and isinstance(parameters, dict):
|
||||
input_schema: ToolInputSchema = {"json": parameters}
|
||||
tool_spec["inputSchema"] = input_schema
|
||||
|
||||
converse_tool: ConverseToolTypeDef = {"toolSpec": tool_spec}
|
||||
converse_tool["toolSpec"]["inputSchema"] = {"json": parameters}
|
||||
|
||||
converse_tools.append(converse_tool)
|
||||
|
||||
@@ -768,9 +460,9 @@ class BedrockCompletion(BaseLLM):
|
||||
|
||||
return converse_tools
|
||||
|
||||
def _get_inference_config(self) -> EnhancedInferenceConfigurationTypeDef:
|
||||
def _get_inference_config(self) -> dict[str, Any]:
|
||||
"""Get inference configuration following AWS Converse API specification."""
|
||||
config: EnhancedInferenceConfigurationTypeDef = {}
|
||||
config = {}
|
||||
|
||||
if self.max_tokens:
|
||||
config["maxTokens"] = self.max_tokens
|
||||
@@ -811,7 +503,7 @@ class BedrockCompletion(BaseLLM):
|
||||
|
||||
return full_error_msg
|
||||
|
||||
def _track_token_usage_internal(self, usage: TokenUsageTypeDef) -> None: # type: ignore[override]
|
||||
def _track_token_usage_internal(self, usage: dict[str, Any]) -> None:
|
||||
"""Track token usage from Bedrock response."""
|
||||
input_tokens = usage.get("inputTokens", 0)
|
||||
output_tokens = usage.get("outputTokens", 0)
|
||||
|
||||
@@ -16,7 +16,7 @@ try:
|
||||
from google.genai.errors import APIError
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
'Google Gen AI native provider not available, to install: uv add "crewai[google-genai]"'
|
||||
"Google Gen AI native provider not available, to install: `uv add google-genai`"
|
||||
) from None
|
||||
|
||||
|
||||
|
||||
@@ -65,7 +65,6 @@ class OpenAICompletion(BaseLLM):
|
||||
self.client_params = client_params
|
||||
self.timeout = timeout
|
||||
self.base_url = base_url
|
||||
self.api_base = kwargs.pop("api_base", None)
|
||||
|
||||
super().__init__(
|
||||
model=model,
|
||||
@@ -107,10 +106,7 @@ class OpenAICompletion(BaseLLM):
|
||||
"api_key": self.api_key,
|
||||
"organization": self.organization,
|
||||
"project": self.project,
|
||||
"base_url": self.base_url
|
||||
or self.api_base
|
||||
or os.getenv("OPENAI_BASE_URL")
|
||||
or None,
|
||||
"base_url": self.base_url,
|
||||
"timeout": self.timeout,
|
||||
"max_retries": self.max_retries,
|
||||
"default_headers": self.default_headers,
|
||||
@@ -243,7 +239,6 @@ class OpenAICompletion(BaseLLM):
|
||||
"provider",
|
||||
"api_key",
|
||||
"base_url",
|
||||
"api_base",
|
||||
"timeout",
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
"""Project package for CrewAI."""
|
||||
|
||||
from crewai.project.annotations import (
|
||||
from .annotations import (
|
||||
after_kickoff,
|
||||
agent,
|
||||
before_kickoff,
|
||||
@@ -13,8 +11,7 @@ from crewai.project.annotations import (
|
||||
task,
|
||||
tool,
|
||||
)
|
||||
from crewai.project.crew_base import CrewBase
|
||||
|
||||
from .crew_base import CrewBase
|
||||
|
||||
__all__ = [
|
||||
"CrewBase",
|
||||
|
||||
@@ -1,194 +1,95 @@
|
||||
"""Decorators for defining crew components and their behaviors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING, Concatenate, ParamSpec, TypeVar
|
||||
from typing import Callable
|
||||
|
||||
from crewai import Crew
|
||||
from crewai.project.utils import memoize
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from crewai import Agent, Crew, Task
|
||||
|
||||
from crewai.project.wrappers import (
|
||||
AfterKickoffMethod,
|
||||
AgentMethod,
|
||||
BeforeKickoffMethod,
|
||||
CacheHandlerMethod,
|
||||
CallbackMethod,
|
||||
CrewInstance,
|
||||
LLMMethod,
|
||||
OutputJsonClass,
|
||||
OutputPydanticClass,
|
||||
TaskMethod,
|
||||
TaskResultT,
|
||||
ToolMethod,
|
||||
)
|
||||
"""Decorators for defining crew components and their behaviors."""
|
||||
|
||||
|
||||
P = ParamSpec("P")
|
||||
P2 = ParamSpec("P2")
|
||||
R = TypeVar("R")
|
||||
R2 = TypeVar("R2")
|
||||
T = TypeVar("T")
|
||||
def before_kickoff(func):
|
||||
"""Marks a method to execute before crew kickoff."""
|
||||
func.is_before_kickoff = True
|
||||
return func
|
||||
|
||||
|
||||
def before_kickoff(meth: Callable[P, R]) -> BeforeKickoffMethod[P, R]:
|
||||
"""Marks a method to execute before crew kickoff.
|
||||
|
||||
Args:
|
||||
meth: The method to mark.
|
||||
|
||||
Returns:
|
||||
A wrapped method marked for before kickoff execution.
|
||||
"""
|
||||
return BeforeKickoffMethod(meth)
|
||||
def after_kickoff(func):
|
||||
"""Marks a method to execute after crew kickoff."""
|
||||
func.is_after_kickoff = True
|
||||
return func
|
||||
|
||||
|
||||
def after_kickoff(meth: Callable[P, R]) -> AfterKickoffMethod[P, R]:
|
||||
"""Marks a method to execute after crew kickoff.
|
||||
def task(func):
|
||||
"""Marks a method as a crew task."""
|
||||
func.is_task = True
|
||||
|
||||
Args:
|
||||
meth: The method to mark.
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
result = func(*args, **kwargs)
|
||||
if not result.name:
|
||||
result.name = func.__name__
|
||||
return result
|
||||
|
||||
Returns:
|
||||
A wrapped method marked for after kickoff execution.
|
||||
"""
|
||||
return AfterKickoffMethod(meth)
|
||||
return memoize(wrapper)
|
||||
|
||||
|
||||
def task(meth: Callable[P, TaskResultT]) -> TaskMethod[P, TaskResultT]:
|
||||
"""Marks a method as a crew task.
|
||||
|
||||
Args:
|
||||
meth: The method to mark.
|
||||
|
||||
Returns:
|
||||
A wrapped method marked as a task with memoization.
|
||||
"""
|
||||
return TaskMethod(memoize(meth))
|
||||
def agent(func):
|
||||
"""Marks a method as a crew agent."""
|
||||
func.is_agent = True
|
||||
return memoize(func)
|
||||
|
||||
|
||||
def agent(meth: Callable[P, R]) -> AgentMethod[P, R]:
|
||||
"""Marks a method as a crew agent.
|
||||
|
||||
Args:
|
||||
meth: The method to mark.
|
||||
|
||||
Returns:
|
||||
A wrapped method marked as an agent with memoization.
|
||||
"""
|
||||
return AgentMethod(memoize(meth))
|
||||
def llm(func):
|
||||
"""Marks a method as an LLM provider."""
|
||||
func.is_llm = True
|
||||
return memoize(func)
|
||||
|
||||
|
||||
def llm(meth: Callable[P, R]) -> LLMMethod[P, R]:
|
||||
"""Marks a method as an LLM provider.
|
||||
|
||||
Args:
|
||||
meth: The method to mark.
|
||||
|
||||
Returns:
|
||||
A wrapped method marked as an LLM provider with memoization.
|
||||
"""
|
||||
return LLMMethod(memoize(meth))
|
||||
def output_json(cls):
|
||||
"""Marks a class as JSON output format."""
|
||||
cls.is_output_json = True
|
||||
return cls
|
||||
|
||||
|
||||
def output_json(cls: type[T]) -> OutputJsonClass[T]:
|
||||
"""Marks a class as JSON output format.
|
||||
|
||||
Args:
|
||||
cls: The class to mark.
|
||||
|
||||
Returns:
|
||||
A wrapped class marked as JSON output format.
|
||||
"""
|
||||
return OutputJsonClass(cls)
|
||||
def output_pydantic(cls):
|
||||
"""Marks a class as Pydantic output format."""
|
||||
cls.is_output_pydantic = True
|
||||
return cls
|
||||
|
||||
|
||||
def output_pydantic(cls: type[T]) -> OutputPydanticClass[T]:
|
||||
"""Marks a class as Pydantic output format.
|
||||
|
||||
Args:
|
||||
cls: The class to mark.
|
||||
|
||||
Returns:
|
||||
A wrapped class marked as Pydantic output format.
|
||||
"""
|
||||
return OutputPydanticClass(cls)
|
||||
def tool(func):
|
||||
"""Marks a method as a crew tool."""
|
||||
func.is_tool = True
|
||||
return memoize(func)
|
||||
|
||||
|
||||
def tool(meth: Callable[P, R]) -> ToolMethod[P, R]:
|
||||
"""Marks a method as a crew tool.
|
||||
|
||||
Args:
|
||||
meth: The method to mark.
|
||||
|
||||
Returns:
|
||||
A wrapped method marked as a tool with memoization.
|
||||
"""
|
||||
return ToolMethod(memoize(meth))
|
||||
def callback(func):
|
||||
"""Marks a method as a crew callback."""
|
||||
func.is_callback = True
|
||||
return memoize(func)
|
||||
|
||||
|
||||
def callback(meth: Callable[P, R]) -> CallbackMethod[P, R]:
|
||||
"""Marks a method as a crew callback.
|
||||
|
||||
Args:
|
||||
meth: The method to mark.
|
||||
|
||||
Returns:
|
||||
A wrapped method marked as a callback with memoization.
|
||||
"""
|
||||
return CallbackMethod(memoize(meth))
|
||||
def cache_handler(func):
|
||||
"""Marks a method as a cache handler."""
|
||||
func.is_cache_handler = True
|
||||
return memoize(func)
|
||||
|
||||
|
||||
def cache_handler(meth: Callable[P, R]) -> CacheHandlerMethod[P, R]:
|
||||
"""Marks a method as a cache handler.
|
||||
def crew(func) -> Callable[..., Crew]:
|
||||
"""Marks a method as the main crew execution point."""
|
||||
|
||||
Args:
|
||||
meth: The method to mark.
|
||||
|
||||
Returns:
|
||||
A wrapped method marked as a cache handler with memoization.
|
||||
"""
|
||||
return CacheHandlerMethod(memoize(meth))
|
||||
|
||||
|
||||
def crew(
|
||||
meth: Callable[Concatenate[CrewInstance, P], Crew],
|
||||
) -> Callable[Concatenate[CrewInstance, P], Crew]:
|
||||
"""Marks a method as the main crew execution point.
|
||||
|
||||
Args:
|
||||
meth: The method to mark as crew execution point.
|
||||
|
||||
Returns:
|
||||
A wrapped method that instantiates tasks and agents before execution.
|
||||
"""
|
||||
|
||||
@wraps(meth)
|
||||
def wrapper(self: CrewInstance, *args: P.args, **kwargs: P.kwargs) -> Crew:
|
||||
"""Wrapper that sets up crew before calling the decorated method.
|
||||
|
||||
Args:
|
||||
self: The crew class instance.
|
||||
*args: Additional positional arguments.
|
||||
**kwargs: Keyword arguments to pass to the method.
|
||||
|
||||
Returns:
|
||||
The configured Crew instance with callbacks attached.
|
||||
"""
|
||||
instantiated_tasks: list[Task] = []
|
||||
instantiated_agents: list[Agent] = []
|
||||
agent_roles: set[str] = set()
|
||||
@wraps(func)
|
||||
def wrapper(self, *args, **kwargs) -> Crew:
|
||||
instantiated_tasks = []
|
||||
instantiated_agents = []
|
||||
agent_roles = set()
|
||||
|
||||
# Use the preserved task and agent information
|
||||
tasks = self.__crew_metadata__["original_tasks"].items()
|
||||
agents = self.__crew_metadata__["original_agents"].items()
|
||||
tasks = self._original_tasks.items()
|
||||
agents = self._original_agents.items()
|
||||
|
||||
# Instantiate tasks in order
|
||||
for _, task_method in tasks:
|
||||
for _task_name, task_method in tasks:
|
||||
task_instance = task_method(self)
|
||||
instantiated_tasks.append(task_instance)
|
||||
agent_instance = getattr(task_instance, "agent", None)
|
||||
@@ -197,7 +98,7 @@ def crew(
|
||||
agent_roles.add(agent_instance.role)
|
||||
|
||||
# Instantiate agents not included by tasks
|
||||
for _, agent_method in agents:
|
||||
for _agent_name, agent_method in agents:
|
||||
agent_instance = agent_method(self)
|
||||
if agent_instance.role not in agent_roles:
|
||||
instantiated_agents.append(agent_instance)
|
||||
@@ -206,44 +107,19 @@ def crew(
|
||||
self.agents = instantiated_agents
|
||||
self.tasks = instantiated_tasks
|
||||
|
||||
crew_instance = meth(self, *args, **kwargs)
|
||||
crew = func(self, *args, **kwargs)
|
||||
|
||||
def callback_wrapper(
|
||||
hook: Callable[Concatenate[CrewInstance, P2], R2], instance: CrewInstance
|
||||
) -> Callable[P2, R2]:
|
||||
"""Bind a hook callback to an instance.
|
||||
def callback_wrapper(callback, instance):
|
||||
def wrapper(*args, **kwargs):
|
||||
return callback(instance, *args, **kwargs)
|
||||
|
||||
Args:
|
||||
hook: The callback hook to bind.
|
||||
instance: The instance to bind to.
|
||||
return wrapper
|
||||
|
||||
Returns:
|
||||
A bound callback function.
|
||||
"""
|
||||
for callback in self._before_kickoff.values():
|
||||
crew.before_kickoff_callbacks.append(callback_wrapper(callback, self))
|
||||
for callback in self._after_kickoff.values():
|
||||
crew.after_kickoff_callbacks.append(callback_wrapper(callback, self))
|
||||
|
||||
def bound_callback(*cb_args: P2.args, **cb_kwargs: P2.kwargs) -> R2:
|
||||
"""Execute the bound callback.
|
||||
|
||||
Args:
|
||||
*cb_args: Positional arguments for the callback.
|
||||
**cb_kwargs: Keyword arguments for the callback.
|
||||
|
||||
Returns:
|
||||
The result of the callback execution.
|
||||
"""
|
||||
return hook(instance, *cb_args, **cb_kwargs)
|
||||
|
||||
return bound_callback
|
||||
|
||||
for hook_callback in self.__crew_metadata__["before_kickoff"].values():
|
||||
crew_instance.before_kickoff_callbacks.append(
|
||||
callback_wrapper(hook_callback, self)
|
||||
)
|
||||
for hook_callback in self.__crew_metadata__["after_kickoff"].values():
|
||||
crew_instance.after_kickoff_callbacks.append(
|
||||
callback_wrapper(hook_callback, self)
|
||||
)
|
||||
|
||||
return crew_instance
|
||||
return crew
|
||||
|
||||
return memoize(wrapper)
|
||||
|
||||
@@ -1,632 +1,303 @@
|
||||
"""Base metaclass for creating crew classes with configuration and method management."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
import inspect
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Literal, TypeGuard, TypeVar, TypedDict, cast
|
||||
from typing import Any, TypeVar, cast
|
||||
|
||||
from dotenv import load_dotenv
|
||||
import yaml
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from crewai.project.wrappers import CrewClass, CrewMetadata
|
||||
from crewai.tools import BaseTool
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from crewai import Agent, Task
|
||||
from crewai.agents.cache.cache_handler import CacheHandler
|
||||
from crewai.crews.crew_output import CrewOutput
|
||||
from crewai.project.wrappers import (
|
||||
CrewInstance,
|
||||
OutputJsonClass,
|
||||
OutputPydanticClass,
|
||||
)
|
||||
from crewai.tasks.task_output import TaskOutput
|
||||
|
||||
|
||||
class AgentConfig(TypedDict, total=False):
|
||||
"""Type definition for agent configuration dictionary.
|
||||
|
||||
All fields are optional as they come from YAML configuration files.
|
||||
Fields can be either string references (from YAML) or actual instances (after processing).
|
||||
"""
|
||||
|
||||
# Core agent attributes (from BaseAgent)
|
||||
role: str
|
||||
goal: str
|
||||
backstory: str
|
||||
cache: bool
|
||||
verbose: bool
|
||||
max_rpm: int
|
||||
allow_delegation: bool
|
||||
max_iter: int
|
||||
max_tokens: int
|
||||
callbacks: list[str]
|
||||
|
||||
# LLM configuration
|
||||
llm: str
|
||||
function_calling_llm: str
|
||||
use_system_prompt: bool
|
||||
|
||||
# Template configuration
|
||||
system_template: str
|
||||
prompt_template: str
|
||||
response_template: str
|
||||
|
||||
# Tools and handlers (can be string references or instances)
|
||||
tools: list[str] | list[BaseTool]
|
||||
step_callback: str
|
||||
cache_handler: str | CacheHandler
|
||||
|
||||
# Code execution
|
||||
allow_code_execution: bool
|
||||
code_execution_mode: Literal["safe", "unsafe"]
|
||||
|
||||
# Context and performance
|
||||
respect_context_window: bool
|
||||
max_retry_limit: int
|
||||
|
||||
# Multimodal and reasoning
|
||||
multimodal: bool
|
||||
reasoning: bool
|
||||
max_reasoning_attempts: int
|
||||
|
||||
# Knowledge configuration
|
||||
knowledge_sources: list[str] | list[Any]
|
||||
knowledge_storage: str | Any
|
||||
knowledge_config: dict[str, Any]
|
||||
embedder: dict[str, Any]
|
||||
agent_knowledge_context: str
|
||||
crew_knowledge_context: str
|
||||
knowledge_search_query: str
|
||||
|
||||
# Misc configuration
|
||||
inject_date: bool
|
||||
date_format: str
|
||||
from_repository: str
|
||||
guardrail: Callable[[Any], tuple[bool, Any]] | str
|
||||
guardrail_max_retries: int
|
||||
|
||||
|
||||
class TaskConfig(TypedDict, total=False):
|
||||
"""Type definition for task configuration dictionary.
|
||||
|
||||
All fields are optional as they come from YAML configuration files.
|
||||
Fields can be either string references (from YAML) or actual instances (after processing).
|
||||
"""
|
||||
|
||||
# Core task attributes
|
||||
name: str
|
||||
description: str
|
||||
expected_output: str
|
||||
|
||||
# Agent and context
|
||||
agent: str
|
||||
context: list[str]
|
||||
|
||||
# Tools and callbacks (can be string references or instances)
|
||||
tools: list[str] | list[BaseTool]
|
||||
callback: str
|
||||
callbacks: list[str]
|
||||
|
||||
# Output configuration
|
||||
output_json: str
|
||||
output_pydantic: str
|
||||
output_file: str
|
||||
create_directory: bool
|
||||
|
||||
# Execution configuration
|
||||
async_execution: bool
|
||||
human_input: bool
|
||||
markdown: bool
|
||||
|
||||
# Guardrail configuration
|
||||
guardrail: Callable[[TaskOutput], tuple[bool, Any]] | str
|
||||
guardrail_max_retries: int
|
||||
|
||||
# Misc configuration
|
||||
allow_crewai_trigger_context: bool
|
||||
|
||||
from crewai.utilities.printer import Printer
|
||||
|
||||
load_dotenv()
|
||||
|
||||
CallableT = TypeVar("CallableT", bound=Callable[..., Any])
|
||||
_printer = Printer()
|
||||
T = TypeVar("T", bound=type)
|
||||
|
||||
"""Base decorator for creating crew classes with configuration and function management."""
|
||||
|
||||
|
||||
def _set_base_directory(cls: type[CrewClass]) -> None:
|
||||
"""Set the base directory for the crew class.
|
||||
def CrewBase(cls: T) -> T: # noqa: N802
|
||||
"""Wraps a class with crew functionality and configuration management."""
|
||||
|
||||
Args:
|
||||
cls: Crew class to configure.
|
||||
"""
|
||||
try:
|
||||
cls.base_directory = Path(inspect.getfile(cls)).parent
|
||||
except (TypeError, OSError):
|
||||
cls.base_directory = Path.cwd()
|
||||
class WrappedClass(cls): # type: ignore
|
||||
is_crew_class: bool = True # type: ignore
|
||||
|
||||
# Get the directory of the class being decorated
|
||||
base_directory = Path(inspect.getfile(cls)).parent
|
||||
|
||||
def _set_config_paths(cls: type[CrewClass]) -> None:
|
||||
"""Set the configuration file paths for the crew class.
|
||||
|
||||
Args:
|
||||
cls: Crew class to configure.
|
||||
"""
|
||||
cls.original_agents_config_path = getattr(
|
||||
cls, "agents_config", "config/agents.yaml"
|
||||
)
|
||||
cls.original_tasks_config_path = getattr(cls, "tasks_config", "config/tasks.yaml")
|
||||
|
||||
|
||||
def _set_mcp_params(cls: type[CrewClass]) -> None:
|
||||
"""Set the MCP server parameters for the crew class.
|
||||
|
||||
Args:
|
||||
cls: Crew class to configure.
|
||||
"""
|
||||
cls.mcp_server_params = getattr(cls, "mcp_server_params", None)
|
||||
cls.mcp_connect_timeout = getattr(cls, "mcp_connect_timeout", 30)
|
||||
|
||||
|
||||
def _is_string_list(value: list[str] | list[BaseTool]) -> TypeGuard[list[str]]:
|
||||
"""Type guard to check if list contains strings rather than BaseTool instances.
|
||||
|
||||
Args:
|
||||
value: List that may contain strings or BaseTool instances.
|
||||
|
||||
Returns:
|
||||
True if all elements are strings, False otherwise.
|
||||
"""
|
||||
return all(isinstance(item, str) for item in value)
|
||||
|
||||
|
||||
def _is_string_value(value: str | CacheHandler) -> TypeGuard[str]:
|
||||
"""Type guard to check if value is a string rather than a CacheHandler instance.
|
||||
|
||||
Args:
|
||||
value: Value that may be a string or CacheHandler instance.
|
||||
|
||||
Returns:
|
||||
True if value is a string, False otherwise.
|
||||
"""
|
||||
return isinstance(value, str)
|
||||
|
||||
|
||||
class CrewBaseMeta(type):
|
||||
"""Metaclass that adds crew functionality to classes."""
|
||||
|
||||
def __new__(
|
||||
mcs,
|
||||
name: str,
|
||||
bases: tuple[type, ...],
|
||||
namespace: dict[str, Any],
|
||||
**kwargs: Any,
|
||||
) -> type[CrewClass]:
|
||||
"""Create crew class with configuration and method injection.
|
||||
|
||||
Args:
|
||||
name: Class name.
|
||||
bases: Base classes.
|
||||
namespace: Class namespace dictionary.
|
||||
**kwargs: Additional keyword arguments.
|
||||
|
||||
Returns:
|
||||
New crew class with injected methods and attributes.
|
||||
"""
|
||||
cls = cast(
|
||||
type[CrewClass], cast(object, super().__new__(mcs, name, bases, namespace))
|
||||
original_agents_config_path = getattr(
|
||||
cls, "agents_config", "config/agents.yaml"
|
||||
)
|
||||
original_tasks_config_path = getattr(cls, "tasks_config", "config/tasks.yaml")
|
||||
|
||||
cls.is_crew_class = True
|
||||
cls._crew_name = name
|
||||
mcp_server_params: Any = getattr(cls, "mcp_server_params", None)
|
||||
mcp_connect_timeout: int = getattr(cls, "mcp_connect_timeout", 30)
|
||||
|
||||
for setup_fn in _CLASS_SETUP_FUNCTIONS:
|
||||
setup_fn(cls)
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.load_configurations()
|
||||
self.map_all_agent_variables()
|
||||
self.map_all_task_variables()
|
||||
# Preserve all decorated functions
|
||||
self._original_functions = {
|
||||
name: method
|
||||
for name, method in cls.__dict__.items()
|
||||
if any(
|
||||
hasattr(method, attr)
|
||||
for attr in [
|
||||
"is_task",
|
||||
"is_agent",
|
||||
"is_before_kickoff",
|
||||
"is_after_kickoff",
|
||||
"is_kickoff",
|
||||
]
|
||||
)
|
||||
}
|
||||
# Store specific function types
|
||||
self._original_tasks = self._filter_functions(
|
||||
self._original_functions, "is_task"
|
||||
)
|
||||
self._original_agents = self._filter_functions(
|
||||
self._original_functions, "is_agent"
|
||||
)
|
||||
self._before_kickoff = self._filter_functions(
|
||||
self._original_functions, "is_before_kickoff"
|
||||
)
|
||||
self._after_kickoff = self._filter_functions(
|
||||
self._original_functions, "is_after_kickoff"
|
||||
)
|
||||
self._kickoff = self._filter_functions(
|
||||
self._original_functions, "is_kickoff"
|
||||
)
|
||||
|
||||
for method in _METHODS_TO_INJECT:
|
||||
setattr(cls, method.__name__, method)
|
||||
# Add close mcp server method to after kickoff
|
||||
bound_method = self._create_close_mcp_server_method()
|
||||
self._after_kickoff["_close_mcp_server"] = bound_method
|
||||
|
||||
return cls
|
||||
def _create_close_mcp_server_method(self):
|
||||
def _close_mcp_server(self, instance, outputs):
|
||||
adapter = getattr(self, "_mcp_server_adapter", None)
|
||||
if adapter is not None:
|
||||
try:
|
||||
adapter.stop()
|
||||
except Exception as e:
|
||||
logging.warning(f"Error stopping MCP server: {e}")
|
||||
return outputs
|
||||
|
||||
def __call__(cls, *args: Any, **kwargs: Any) -> CrewInstance:
|
||||
"""Intercept instance creation to initialize crew functionality.
|
||||
_close_mcp_server.is_after_kickoff = True
|
||||
|
||||
Args:
|
||||
*args: Positional arguments for instance creation.
|
||||
**kwargs: Keyword arguments for instance creation.
|
||||
import types
|
||||
|
||||
Returns:
|
||||
Initialized crew instance.
|
||||
"""
|
||||
instance: CrewInstance = super().__call__(*args, **kwargs)
|
||||
CrewBaseMeta._initialize_crew_instance(instance, cls)
|
||||
return instance
|
||||
return types.MethodType(_close_mcp_server, self)
|
||||
|
||||
@staticmethod
|
||||
def _initialize_crew_instance(instance: CrewInstance, cls: type) -> None:
|
||||
"""Initialize crew instance attributes and load configurations.
|
||||
def get_mcp_tools(self, *tool_names: list[str]) -> list[BaseTool]:
|
||||
if not self.mcp_server_params:
|
||||
return []
|
||||
|
||||
Args:
|
||||
instance: Crew instance to initialize.
|
||||
cls: Crew class type.
|
||||
"""
|
||||
instance._mcp_server_adapter = None
|
||||
instance.load_configurations()
|
||||
instance._all_methods = _get_all_methods(instance)
|
||||
instance.map_all_agent_variables()
|
||||
instance.map_all_task_variables()
|
||||
from crewai_tools import MCPServerAdapter # type: ignore[import-untyped]
|
||||
|
||||
original_methods = {
|
||||
name: method
|
||||
for name, method in cls.__dict__.items()
|
||||
if any(
|
||||
hasattr(method, attr)
|
||||
for attr in [
|
||||
"is_task",
|
||||
"is_agent",
|
||||
"is_before_kickoff",
|
||||
"is_after_kickoff",
|
||||
"is_kickoff",
|
||||
adapter = getattr(self, "_mcp_server_adapter", None)
|
||||
if not adapter:
|
||||
self._mcp_server_adapter = MCPServerAdapter(
|
||||
self.mcp_server_params, connect_timeout=self.mcp_connect_timeout
|
||||
)
|
||||
|
||||
return self._mcp_server_adapter.tools.filter_by_names(tool_names or None)
|
||||
|
||||
def load_configurations(self):
|
||||
"""Load agent and task configurations from YAML files."""
|
||||
if isinstance(self.original_agents_config_path, str):
|
||||
agents_config_path = (
|
||||
self.base_directory / self.original_agents_config_path
|
||||
)
|
||||
try:
|
||||
self.agents_config = self.load_yaml(agents_config_path)
|
||||
except FileNotFoundError:
|
||||
logging.warning(
|
||||
f"Agent config file not found at {agents_config_path}. "
|
||||
"Proceeding with empty agent configurations."
|
||||
)
|
||||
self.agents_config = {}
|
||||
else:
|
||||
logging.warning(
|
||||
"No agent configuration path provided. Proceeding with empty agent configurations."
|
||||
)
|
||||
self.agents_config = {}
|
||||
|
||||
if isinstance(self.original_tasks_config_path, str):
|
||||
tasks_config_path = (
|
||||
self.base_directory / self.original_tasks_config_path
|
||||
)
|
||||
try:
|
||||
self.tasks_config = self.load_yaml(tasks_config_path)
|
||||
except FileNotFoundError:
|
||||
logging.warning(
|
||||
f"Task config file not found at {tasks_config_path}. "
|
||||
"Proceeding with empty task configurations."
|
||||
)
|
||||
self.tasks_config = {}
|
||||
else:
|
||||
logging.warning(
|
||||
"No task configuration path provided. Proceeding with empty task configurations."
|
||||
)
|
||||
self.tasks_config = {}
|
||||
|
||||
@staticmethod
|
||||
def load_yaml(config_path: Path):
|
||||
try:
|
||||
with open(config_path, "r", encoding="utf-8") as file:
|
||||
return yaml.safe_load(file)
|
||||
except FileNotFoundError:
|
||||
_printer.print(f"File not found: {config_path}", color="red")
|
||||
raise
|
||||
|
||||
def _get_all_functions(self):
|
||||
return {
|
||||
name: getattr(self, name)
|
||||
for name in dir(self)
|
||||
if callable(getattr(self, name))
|
||||
}
|
||||
|
||||
def _filter_functions(
|
||||
self, functions: dict[str, Callable], attribute: str
|
||||
) -> dict[str, Callable]:
|
||||
return {
|
||||
name: func
|
||||
for name, func in functions.items()
|
||||
if hasattr(func, attribute)
|
||||
}
|
||||
|
||||
def map_all_agent_variables(self) -> None:
|
||||
all_functions = self._get_all_functions()
|
||||
llms = self._filter_functions(all_functions, "is_llm")
|
||||
tool_functions = self._filter_functions(all_functions, "is_tool")
|
||||
cache_handler_functions = self._filter_functions(
|
||||
all_functions, "is_cache_handler"
|
||||
)
|
||||
callbacks = self._filter_functions(all_functions, "is_callback")
|
||||
|
||||
for agent_name, agent_info in self.agents_config.items():
|
||||
self._map_agent_variables(
|
||||
agent_name,
|
||||
agent_info,
|
||||
llms,
|
||||
tool_functions,
|
||||
cache_handler_functions,
|
||||
callbacks,
|
||||
)
|
||||
|
||||
def _map_agent_variables(
|
||||
self,
|
||||
agent_name: str,
|
||||
agent_info: dict[str, Any],
|
||||
llms: dict[str, Callable],
|
||||
tool_functions: dict[str, Callable],
|
||||
cache_handler_functions: dict[str, Callable],
|
||||
callbacks: dict[str, Callable],
|
||||
) -> None:
|
||||
if llm := agent_info.get("llm"):
|
||||
try:
|
||||
self.agents_config[agent_name]["llm"] = llms[llm]()
|
||||
except KeyError:
|
||||
self.agents_config[agent_name]["llm"] = llm
|
||||
|
||||
if tools := agent_info.get("tools"):
|
||||
self.agents_config[agent_name]["tools"] = [
|
||||
tool_functions[tool]() for tool in tools
|
||||
]
|
||||
|
||||
if function_calling_llm := agent_info.get("function_calling_llm"):
|
||||
try:
|
||||
self.agents_config[agent_name]["function_calling_llm"] = llms[
|
||||
function_calling_llm
|
||||
]()
|
||||
except KeyError:
|
||||
self.agents_config[agent_name]["function_calling_llm"] = (
|
||||
function_calling_llm
|
||||
)
|
||||
|
||||
if step_callback := agent_info.get("step_callback"):
|
||||
self.agents_config[agent_name]["step_callback"] = callbacks[
|
||||
step_callback
|
||||
]()
|
||||
|
||||
if cache_handler := agent_info.get("cache_handler"):
|
||||
self.agents_config[agent_name]["cache_handler"] = (
|
||||
cache_handler_functions[cache_handler]()
|
||||
)
|
||||
|
||||
def map_all_task_variables(self) -> None:
|
||||
all_functions = self._get_all_functions()
|
||||
agents = self._filter_functions(all_functions, "is_agent")
|
||||
tasks = self._filter_functions(all_functions, "is_task")
|
||||
output_json_functions = self._filter_functions(
|
||||
all_functions, "is_output_json"
|
||||
)
|
||||
}
|
||||
|
||||
after_kickoff_callbacks = _filter_methods(original_methods, "is_after_kickoff")
|
||||
after_kickoff_callbacks["close_mcp_server"] = instance.close_mcp_server
|
||||
|
||||
instance.__crew_metadata__ = CrewMetadata(
|
||||
original_methods=original_methods,
|
||||
original_tasks=_filter_methods(original_methods, "is_task"),
|
||||
original_agents=_filter_methods(original_methods, "is_agent"),
|
||||
before_kickoff=_filter_methods(original_methods, "is_before_kickoff"),
|
||||
after_kickoff=after_kickoff_callbacks,
|
||||
kickoff=_filter_methods(original_methods, "is_kickoff"),
|
||||
)
|
||||
|
||||
|
||||
def close_mcp_server(
|
||||
self: CrewInstance, _instance: CrewInstance, outputs: CrewOutput
|
||||
) -> CrewOutput:
|
||||
"""Stop MCP server adapter and return outputs.
|
||||
|
||||
Args:
|
||||
self: Crew instance with MCP server adapter.
|
||||
_instance: Crew instance (unused, required by callback signature).
|
||||
outputs: Crew execution outputs.
|
||||
|
||||
Returns:
|
||||
Unmodified crew outputs.
|
||||
"""
|
||||
if self._mcp_server_adapter is not None:
|
||||
try:
|
||||
self._mcp_server_adapter.stop()
|
||||
except Exception as e:
|
||||
logging.warning(f"Error stopping MCP server: {e}")
|
||||
return outputs
|
||||
|
||||
|
||||
def get_mcp_tools(self: CrewInstance, *tool_names: str) -> list[BaseTool]:
|
||||
"""Get MCP tools filtered by name.
|
||||
|
||||
Args:
|
||||
self: Crew instance with MCP server configuration.
|
||||
*tool_names: Optional tool names to filter by.
|
||||
|
||||
Returns:
|
||||
List of filtered MCP tools, or empty list if no MCP server configured.
|
||||
"""
|
||||
if not self.mcp_server_params:
|
||||
return []
|
||||
|
||||
from crewai_tools import MCPServerAdapter # type: ignore[import-untyped]
|
||||
|
||||
if self._mcp_server_adapter is None:
|
||||
self._mcp_server_adapter = MCPServerAdapter(
|
||||
self.mcp_server_params, connect_timeout=self.mcp_connect_timeout
|
||||
)
|
||||
|
||||
return self._mcp_server_adapter.tools.filter_by_names(tool_names or None)
|
||||
|
||||
|
||||
def _load_config(
|
||||
self: CrewInstance, config_path: str | None, config_type: Literal["agent", "task"]
|
||||
) -> dict[str, Any]:
|
||||
"""Load YAML config file or return empty dict if not found.
|
||||
|
||||
Args:
|
||||
self: Crew instance with base directory and load_yaml method.
|
||||
config_path: Relative path to config file.
|
||||
config_type: Config type for logging, either "agent" or "task".
|
||||
|
||||
Returns:
|
||||
Config dictionary or empty dict.
|
||||
"""
|
||||
if isinstance(config_path, str):
|
||||
full_path = self.base_directory / config_path
|
||||
try:
|
||||
return self.load_yaml(full_path)
|
||||
except FileNotFoundError:
|
||||
logging.warning(
|
||||
f"{config_type.capitalize()} config file not found at {full_path}. "
|
||||
f"Proceeding with empty {config_type} configurations."
|
||||
tool_functions = self._filter_functions(all_functions, "is_tool")
|
||||
callback_functions = self._filter_functions(all_functions, "is_callback")
|
||||
output_pydantic_functions = self._filter_functions(
|
||||
all_functions, "is_output_pydantic"
|
||||
)
|
||||
return {}
|
||||
else:
|
||||
logging.warning(
|
||||
f"No {config_type} configuration path provided. "
|
||||
f"Proceeding with empty {config_type} configurations."
|
||||
)
|
||||
return {}
|
||||
|
||||
for task_name, task_info in self.tasks_config.items():
|
||||
self._map_task_variables(
|
||||
task_name,
|
||||
task_info,
|
||||
agents,
|
||||
tasks,
|
||||
output_json_functions,
|
||||
tool_functions,
|
||||
callback_functions,
|
||||
output_pydantic_functions,
|
||||
)
|
||||
|
||||
def load_configurations(self: CrewInstance) -> None:
|
||||
"""Load agent and task YAML configurations.
|
||||
def _map_task_variables(
|
||||
self,
|
||||
task_name: str,
|
||||
task_info: dict[str, Any],
|
||||
agents: dict[str, Callable],
|
||||
tasks: dict[str, Callable],
|
||||
output_json_functions: dict[str, Callable],
|
||||
tool_functions: dict[str, Callable],
|
||||
callback_functions: dict[str, Callable],
|
||||
output_pydantic_functions: dict[str, Callable],
|
||||
) -> None:
|
||||
if context_list := task_info.get("context"):
|
||||
self.tasks_config[task_name]["context"] = [
|
||||
tasks[context_task_name]() for context_task_name in context_list
|
||||
]
|
||||
|
||||
Args:
|
||||
self: Crew instance with configuration paths.
|
||||
"""
|
||||
self.agents_config = self._load_config(self.original_agents_config_path, "agent")
|
||||
self.tasks_config = self._load_config(self.original_tasks_config_path, "task")
|
||||
if tools := task_info.get("tools"):
|
||||
self.tasks_config[task_name]["tools"] = [
|
||||
tool_functions[tool]() for tool in tools
|
||||
]
|
||||
|
||||
if agent_name := task_info.get("agent"):
|
||||
self.tasks_config[task_name]["agent"] = agents[agent_name]()
|
||||
|
||||
def load_yaml(config_path: Path) -> dict[str, Any]:
|
||||
"""Load and parse YAML configuration file.
|
||||
if output_json := task_info.get("output_json"):
|
||||
self.tasks_config[task_name]["output_json"] = output_json_functions[
|
||||
output_json
|
||||
]
|
||||
|
||||
Args:
|
||||
config_path: Path to YAML configuration file.
|
||||
if output_pydantic := task_info.get("output_pydantic"):
|
||||
self.tasks_config[task_name]["output_pydantic"] = (
|
||||
output_pydantic_functions[output_pydantic]
|
||||
)
|
||||
|
||||
Returns:
|
||||
Parsed YAML content as a dictionary. Returns empty dict if file is empty.
|
||||
if callbacks := task_info.get("callbacks"):
|
||||
self.tasks_config[task_name]["callbacks"] = [
|
||||
callback_functions[callback]() for callback in callbacks
|
||||
]
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If config file does not exist.
|
||||
"""
|
||||
try:
|
||||
with open(config_path, encoding="utf-8") as file:
|
||||
content = yaml.safe_load(file)
|
||||
return content if isinstance(content, dict) else {}
|
||||
except FileNotFoundError:
|
||||
logging.warning(f"File not found: {config_path}")
|
||||
raise
|
||||
if guardrail := task_info.get("guardrail"):
|
||||
self.tasks_config[task_name]["guardrail"] = guardrail
|
||||
|
||||
# Include base class (qual)name in the wrapper class (qual)name.
|
||||
WrappedClass.__name__ = CrewBase.__name__ + "(" + cls.__name__ + ")"
|
||||
WrappedClass.__qualname__ = CrewBase.__qualname__ + "(" + cls.__name__ + ")"
|
||||
WrappedClass._crew_name = cls.__name__
|
||||
|
||||
def _get_all_methods(self: CrewInstance) -> dict[str, Callable[..., Any]]:
|
||||
"""Return all non-dunder callable attributes (methods).
|
||||
|
||||
Args:
|
||||
self: Instance to inspect for callable attributes.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping method names to bound method objects.
|
||||
"""
|
||||
return {
|
||||
name: getattr(self, name)
|
||||
for name in dir(self)
|
||||
if not (name.startswith("__") and name.endswith("__"))
|
||||
and callable(getattr(self, name, None))
|
||||
}
|
||||
|
||||
|
||||
def _filter_methods(
|
||||
methods: dict[str, CallableT], attribute: str
|
||||
) -> dict[str, CallableT]:
|
||||
"""Filter methods by attribute presence, preserving exact callable types.
|
||||
|
||||
Args:
|
||||
methods: Dictionary of methods to filter.
|
||||
attribute: Attribute name to check for.
|
||||
|
||||
Returns:
|
||||
Dictionary containing only methods with the specified attribute.
|
||||
The return type matches the input callable type exactly.
|
||||
"""
|
||||
return {
|
||||
name: method for name, method in methods.items() if hasattr(method, attribute)
|
||||
}
|
||||
|
||||
|
||||
def map_all_agent_variables(self: CrewInstance) -> None:
|
||||
"""Map agent configuration variables to callable instances.
|
||||
|
||||
Args:
|
||||
self: Crew instance with agent configurations to map.
|
||||
"""
|
||||
llms = _filter_methods(self._all_methods, "is_llm")
|
||||
tool_functions = _filter_methods(self._all_methods, "is_tool")
|
||||
cache_handler_functions = _filter_methods(self._all_methods, "is_cache_handler")
|
||||
callbacks = _filter_methods(self._all_methods, "is_callback")
|
||||
|
||||
for agent_name, agent_info in self.agents_config.items():
|
||||
self._map_agent_variables(
|
||||
agent_name=agent_name,
|
||||
agent_info=agent_info,
|
||||
llms=llms,
|
||||
tool_functions=tool_functions,
|
||||
cache_handler_functions=cache_handler_functions,
|
||||
callbacks=callbacks,
|
||||
)
|
||||
|
||||
|
||||
def _map_agent_variables(
|
||||
self: CrewInstance,
|
||||
agent_name: str,
|
||||
agent_info: AgentConfig,
|
||||
llms: dict[str, Callable[[], Any]],
|
||||
tool_functions: dict[str, Callable[[], BaseTool]],
|
||||
cache_handler_functions: dict[str, Callable[[], Any]],
|
||||
callbacks: dict[str, Callable[..., Any]],
|
||||
) -> None:
|
||||
"""Resolve and map variables for a single agent.
|
||||
|
||||
Args:
|
||||
self: Crew instance with agent configurations.
|
||||
agent_name: Name of agent to configure.
|
||||
agent_info: Agent configuration dictionary with optional fields.
|
||||
llms: Dictionary mapping names to LLM factory functions.
|
||||
tool_functions: Dictionary mapping names to tool factory functions.
|
||||
cache_handler_functions: Dictionary mapping names to cache handler factory functions.
|
||||
callbacks: Dictionary of available callbacks.
|
||||
"""
|
||||
if llm := agent_info.get("llm"):
|
||||
factory = llms.get(llm)
|
||||
self.agents_config[agent_name]["llm"] = factory() if factory else llm
|
||||
|
||||
if tools := agent_info.get("tools"):
|
||||
if _is_string_list(tools):
|
||||
self.agents_config[agent_name]["tools"] = [
|
||||
tool_functions[tool]() for tool in tools
|
||||
]
|
||||
|
||||
if function_calling_llm := agent_info.get("function_calling_llm"):
|
||||
factory = llms.get(function_calling_llm)
|
||||
self.agents_config[agent_name]["function_calling_llm"] = (
|
||||
factory() if factory else function_calling_llm
|
||||
)
|
||||
|
||||
if step_callback := agent_info.get("step_callback"):
|
||||
self.agents_config[agent_name]["step_callback"] = callbacks[step_callback]()
|
||||
|
||||
if cache_handler := agent_info.get("cache_handler"):
|
||||
if _is_string_value(cache_handler):
|
||||
self.agents_config[agent_name]["cache_handler"] = cache_handler_functions[
|
||||
cache_handler
|
||||
]()
|
||||
|
||||
|
||||
def map_all_task_variables(self: CrewInstance) -> None:
|
||||
"""Map task configuration variables to callable instances.
|
||||
|
||||
Args:
|
||||
self: Crew instance with task configurations to map.
|
||||
"""
|
||||
agents = _filter_methods(self._all_methods, "is_agent")
|
||||
tasks = _filter_methods(self._all_methods, "is_task")
|
||||
output_json_functions = _filter_methods(self._all_methods, "is_output_json")
|
||||
tool_functions = _filter_methods(self._all_methods, "is_tool")
|
||||
callback_functions = _filter_methods(self._all_methods, "is_callback")
|
||||
output_pydantic_functions = _filter_methods(self._all_methods, "is_output_pydantic")
|
||||
|
||||
for task_name, task_info in self.tasks_config.items():
|
||||
self._map_task_variables(
|
||||
task_name=task_name,
|
||||
task_info=task_info,
|
||||
agents=agents,
|
||||
tasks=tasks,
|
||||
output_json_functions=output_json_functions,
|
||||
tool_functions=tool_functions,
|
||||
callback_functions=callback_functions,
|
||||
output_pydantic_functions=output_pydantic_functions,
|
||||
)
|
||||
|
||||
|
||||
def _map_task_variables(
|
||||
self: CrewInstance,
|
||||
task_name: str,
|
||||
task_info: TaskConfig,
|
||||
agents: dict[str, Callable[[], Agent]],
|
||||
tasks: dict[str, Callable[[], Task]],
|
||||
output_json_functions: dict[str, OutputJsonClass[Any]],
|
||||
tool_functions: dict[str, Callable[[], BaseTool]],
|
||||
callback_functions: dict[str, Callable[..., Any]],
|
||||
output_pydantic_functions: dict[str, OutputPydanticClass[Any]],
|
||||
) -> None:
|
||||
"""Resolve and map variables for a single task.
|
||||
|
||||
Args:
|
||||
self: Crew instance with task configurations.
|
||||
task_name: Name of task to configure.
|
||||
task_info: Task configuration dictionary with optional fields.
|
||||
agents: Dictionary mapping names to agent factory functions.
|
||||
tasks: Dictionary mapping names to task factory functions.
|
||||
output_json_functions: Dictionary of JSON output class wrappers.
|
||||
tool_functions: Dictionary mapping names to tool factory functions.
|
||||
callback_functions: Dictionary of available callbacks.
|
||||
output_pydantic_functions: Dictionary of Pydantic output class wrappers.
|
||||
"""
|
||||
if context_list := task_info.get("context"):
|
||||
self.tasks_config[task_name]["context"] = [
|
||||
tasks[context_task_name]() for context_task_name in context_list
|
||||
]
|
||||
|
||||
if tools := task_info.get("tools"):
|
||||
if _is_string_list(tools):
|
||||
self.tasks_config[task_name]["tools"] = [
|
||||
tool_functions[tool]() for tool in tools
|
||||
]
|
||||
|
||||
if agent_name := task_info.get("agent"):
|
||||
self.tasks_config[task_name]["agent"] = agents[agent_name]()
|
||||
|
||||
if output_json := task_info.get("output_json"):
|
||||
self.tasks_config[task_name]["output_json"] = output_json_functions[output_json]
|
||||
|
||||
if output_pydantic := task_info.get("output_pydantic"):
|
||||
self.tasks_config[task_name]["output_pydantic"] = output_pydantic_functions[
|
||||
output_pydantic
|
||||
]
|
||||
|
||||
if callbacks := task_info.get("callbacks"):
|
||||
self.tasks_config[task_name]["callbacks"] = [
|
||||
callback_functions[callback]() for callback in callbacks
|
||||
]
|
||||
|
||||
if guardrail := task_info.get("guardrail"):
|
||||
self.tasks_config[task_name]["guardrail"] = guardrail
|
||||
|
||||
|
||||
_CLASS_SETUP_FUNCTIONS: tuple[Callable[[type[CrewClass]], None], ...] = (
|
||||
_set_base_directory,
|
||||
_set_config_paths,
|
||||
_set_mcp_params,
|
||||
)
|
||||
|
||||
_METHODS_TO_INJECT = (
|
||||
close_mcp_server,
|
||||
get_mcp_tools,
|
||||
_load_config,
|
||||
load_configurations,
|
||||
staticmethod(load_yaml),
|
||||
map_all_agent_variables,
|
||||
_map_agent_variables,
|
||||
map_all_task_variables,
|
||||
_map_task_variables,
|
||||
)
|
||||
|
||||
|
||||
class _CrewBaseType(type):
|
||||
"""Metaclass for CrewBase that makes it callable as a decorator."""
|
||||
|
||||
def __call__(cls, decorated_cls: type) -> type[CrewClass]:
|
||||
"""Apply CrewBaseMeta to the decorated class.
|
||||
|
||||
Args:
|
||||
decorated_cls: Class to transform with CrewBaseMeta metaclass.
|
||||
|
||||
Returns:
|
||||
New class with CrewBaseMeta metaclass applied.
|
||||
"""
|
||||
__name = str(decorated_cls.__name__)
|
||||
__bases = tuple(decorated_cls.__bases__)
|
||||
__dict = {
|
||||
key: value
|
||||
for key, value in decorated_cls.__dict__.items()
|
||||
if key not in ("__dict__", "__weakref__")
|
||||
}
|
||||
for slot in __dict.get("__slots__", tuple()):
|
||||
__dict.pop(slot, None)
|
||||
__dict["__metaclass__"] = CrewBaseMeta
|
||||
return cast(type[CrewClass], CrewBaseMeta(__name, __bases, __dict))
|
||||
|
||||
|
||||
class CrewBase(metaclass=_CrewBaseType):
|
||||
"""Class decorator that applies CrewBaseMeta metaclass.
|
||||
|
||||
Applies CrewBaseMeta metaclass to a class via decorator syntax rather than
|
||||
explicit metaclass declaration. Use as @CrewBase instead of
|
||||
class Foo(metaclass=CrewBaseMeta).
|
||||
|
||||
Note:
|
||||
Reference: https://stackoverflow.com/questions/11091609/setting-a-class-metaclass-using-a-decorator
|
||||
"""
|
||||
return cast(T, WrappedClass)
|
||||
|
||||
@@ -1,42 +1,14 @@
|
||||
"""Utility functions for the crewai project module."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from typing import Any, ParamSpec, TypeVar
|
||||
|
||||
|
||||
P = ParamSpec("P")
|
||||
R = TypeVar("R")
|
||||
def memoize(func):
|
||||
cache = {}
|
||||
|
||||
|
||||
def memoize(meth: Callable[P, R]) -> Callable[P, R]:
|
||||
"""Memoize a method by caching its results based on arguments.
|
||||
|
||||
Args:
|
||||
meth: The method to memoize.
|
||||
|
||||
Returns:
|
||||
A memoized version of the method that caches results.
|
||||
|
||||
Notes:
|
||||
- TODO: Need to make this thread-safe for concurrent access, prevent memory leaks.
|
||||
"""
|
||||
cache: dict[Any, R] = {}
|
||||
|
||||
@wraps(meth)
|
||||
def memoized_func(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||
"""Memoized wrapper method.
|
||||
|
||||
Args:
|
||||
*args: Positional arguments to pass to the method.
|
||||
**kwargs: Keyword arguments to pass to the method.
|
||||
|
||||
Returns:
|
||||
The cached or computed result of the method.
|
||||
"""
|
||||
@wraps(func)
|
||||
def memoized_func(*args, **kwargs):
|
||||
key = (args, tuple(kwargs.items()))
|
||||
if key not in cache:
|
||||
cache[key] = meth(*args, **kwargs)
|
||||
cache[key] = func(*args, **kwargs)
|
||||
return cache[key]
|
||||
|
||||
return memoized_func
|
||||
|
||||
@@ -1,389 +0,0 @@
|
||||
"""Wrapper classes for decorated methods with type-safe metadata."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Generic,
|
||||
Literal,
|
||||
ParamSpec,
|
||||
Protocol,
|
||||
TypeVar,
|
||||
TypedDict,
|
||||
)
|
||||
|
||||
from typing_extensions import Self
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from crewai import Agent, Task
|
||||
from crewai.crews.crew_output import CrewOutput
|
||||
from crewai.tools import BaseTool
|
||||
|
||||
|
||||
class CrewMetadata(TypedDict):
|
||||
"""Type definition for crew metadata dictionary.
|
||||
|
||||
Stores framework-injected metadata about decorated methods and callbacks.
|
||||
"""
|
||||
|
||||
original_methods: dict[str, Callable[..., Any]]
|
||||
original_tasks: dict[str, Callable[..., Task]]
|
||||
original_agents: dict[str, Callable[..., Agent]]
|
||||
before_kickoff: dict[str, Callable[..., Any]]
|
||||
after_kickoff: dict[str, Callable[..., Any]]
|
||||
kickoff: dict[str, Callable[..., Any]]
|
||||
|
||||
|
||||
P = ParamSpec("P")
|
||||
R = TypeVar("R")
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class TaskResult(Protocol):
|
||||
"""Protocol for task objects that have a name attribute."""
|
||||
|
||||
name: str | None
|
||||
|
||||
|
||||
TaskResultT = TypeVar("TaskResultT", bound=TaskResult)
|
||||
|
||||
|
||||
def _copy_method_metadata(wrapper: Any, meth: Callable[..., Any]) -> None:
|
||||
"""Copy method metadata to a wrapper object.
|
||||
|
||||
Args:
|
||||
wrapper: The wrapper object to update.
|
||||
meth: The method to copy metadata from.
|
||||
"""
|
||||
wrapper.__name__ = meth.__name__
|
||||
wrapper.__doc__ = meth.__doc__
|
||||
|
||||
|
||||
class CrewInstance(Protocol):
|
||||
"""Protocol for crew class instances with required attributes."""
|
||||
|
||||
__crew_metadata__: CrewMetadata
|
||||
_mcp_server_adapter: Any
|
||||
_all_methods: dict[str, Callable[..., Any]]
|
||||
agents: list[Agent]
|
||||
tasks: list[Task]
|
||||
base_directory: Path
|
||||
original_agents_config_path: str
|
||||
original_tasks_config_path: str
|
||||
agents_config: dict[str, Any]
|
||||
tasks_config: dict[str, Any]
|
||||
mcp_server_params: Any
|
||||
mcp_connect_timeout: int
|
||||
|
||||
def load_configurations(self) -> None: ...
|
||||
def map_all_agent_variables(self) -> None: ...
|
||||
def map_all_task_variables(self) -> None: ...
|
||||
def close_mcp_server(self, instance: Self, outputs: CrewOutput) -> CrewOutput: ...
|
||||
def _load_config(
|
||||
self, config_path: str | None, config_type: Literal["agent", "task"]
|
||||
) -> dict[str, Any]: ...
|
||||
def _map_agent_variables(
|
||||
self,
|
||||
agent_name: str,
|
||||
agent_info: dict[str, Any],
|
||||
llms: dict[str, Callable[..., Any]],
|
||||
tool_functions: dict[str, Callable[..., Any]],
|
||||
cache_handler_functions: dict[str, Callable[..., Any]],
|
||||
callbacks: dict[str, Callable[..., Any]],
|
||||
) -> None: ...
|
||||
def _map_task_variables(
|
||||
self,
|
||||
task_name: str,
|
||||
task_info: dict[str, Any],
|
||||
agents: dict[str, Callable[..., Any]],
|
||||
tasks: dict[str, Callable[..., Any]],
|
||||
output_json_functions: dict[str, Callable[..., Any]],
|
||||
tool_functions: dict[str, Callable[..., Any]],
|
||||
callback_functions: dict[str, Callable[..., Any]],
|
||||
output_pydantic_functions: dict[str, Callable[..., Any]],
|
||||
) -> None: ...
|
||||
def load_yaml(self, config_path: Path) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
class CrewClass(Protocol):
|
||||
"""Protocol describing class attributes injected by CrewBaseMeta."""
|
||||
|
||||
is_crew_class: bool
|
||||
_crew_name: str
|
||||
base_directory: Path
|
||||
original_agents_config_path: str
|
||||
original_tasks_config_path: str
|
||||
mcp_server_params: Any
|
||||
mcp_connect_timeout: int
|
||||
close_mcp_server: Callable[..., Any]
|
||||
get_mcp_tools: Callable[..., list[BaseTool]]
|
||||
_load_config: Callable[..., dict[str, Any]]
|
||||
load_configurations: Callable[..., None]
|
||||
load_yaml: staticmethod
|
||||
map_all_agent_variables: Callable[..., None]
|
||||
_map_agent_variables: Callable[..., None]
|
||||
map_all_task_variables: Callable[..., None]
|
||||
_map_task_variables: Callable[..., None]
|
||||
|
||||
|
||||
class DecoratedMethod(Generic[P, R]):
|
||||
"""Base wrapper for methods with decorator metadata.
|
||||
|
||||
This class provides a type-safe way to add metadata to methods
|
||||
while preserving their callable signature and attributes.
|
||||
"""
|
||||
|
||||
def __init__(self, meth: Callable[P, R]) -> None:
|
||||
"""Initialize the decorated method wrapper.
|
||||
|
||||
Args:
|
||||
meth: The method to wrap.
|
||||
"""
|
||||
self._meth = meth
|
||||
_copy_method_metadata(self, meth)
|
||||
|
||||
def __get__(
|
||||
self, obj: Any, objtype: type[Any] | None = None
|
||||
) -> Self | Callable[..., R]:
|
||||
"""Support instance methods by implementing the descriptor protocol.
|
||||
|
||||
Args:
|
||||
obj: The instance that the method is accessed through.
|
||||
objtype: The type of the instance.
|
||||
|
||||
Returns:
|
||||
Self when accessed through class, bound method when accessed through instance.
|
||||
"""
|
||||
if obj is None:
|
||||
return self
|
||||
bound = partial(self._meth, obj)
|
||||
for attr in (
|
||||
"is_agent",
|
||||
"is_llm",
|
||||
"is_tool",
|
||||
"is_callback",
|
||||
"is_cache_handler",
|
||||
"is_before_kickoff",
|
||||
"is_after_kickoff",
|
||||
"is_crew",
|
||||
):
|
||||
if hasattr(self, attr):
|
||||
setattr(bound, attr, getattr(self, attr))
|
||||
return bound
|
||||
|
||||
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R:
|
||||
"""Call the wrapped method.
|
||||
|
||||
Args:
|
||||
*args: Positional arguments.
|
||||
**kwargs: Keyword arguments.
|
||||
|
||||
Returns:
|
||||
The result of calling the wrapped method.
|
||||
"""
|
||||
return self._meth(*args, **kwargs)
|
||||
|
||||
def unwrap(self) -> Callable[P, R]:
|
||||
"""Get the original unwrapped method.
|
||||
|
||||
Returns:
|
||||
The original method before decoration.
|
||||
"""
|
||||
return self._meth
|
||||
|
||||
|
||||
class BeforeKickoffMethod(DecoratedMethod[P, R]):
|
||||
"""Wrapper for methods marked to execute before crew kickoff."""
|
||||
|
||||
is_before_kickoff: bool = True
|
||||
|
||||
|
||||
class AfterKickoffMethod(DecoratedMethod[P, R]):
|
||||
"""Wrapper for methods marked to execute after crew kickoff."""
|
||||
|
||||
is_after_kickoff: bool = True
|
||||
|
||||
|
||||
class BoundTaskMethod(Generic[TaskResultT]):
|
||||
"""Bound task method with task marker attribute."""
|
||||
|
||||
is_task: bool = True
|
||||
|
||||
def __init__(self, task_method: TaskMethod[Any, TaskResultT], obj: Any) -> None:
|
||||
"""Initialize the bound task method.
|
||||
|
||||
Args:
|
||||
task_method: The TaskMethod descriptor instance.
|
||||
obj: The instance to bind to.
|
||||
"""
|
||||
self._task_method = task_method
|
||||
self._obj = obj
|
||||
|
||||
def __call__(self, *args: Any, **kwargs: Any) -> TaskResultT:
|
||||
"""Execute the bound task method.
|
||||
|
||||
Args:
|
||||
*args: Positional arguments.
|
||||
**kwargs: Keyword arguments.
|
||||
|
||||
Returns:
|
||||
The task result with name ensured.
|
||||
"""
|
||||
result = self._task_method.unwrap()(self._obj, *args, **kwargs)
|
||||
return self._task_method.ensure_task_name(result)
|
||||
|
||||
|
||||
class TaskMethod(Generic[P, TaskResultT]):
|
||||
"""Wrapper for methods marked as crew tasks."""
|
||||
|
||||
is_task: bool = True
|
||||
|
||||
def __init__(self, meth: Callable[P, TaskResultT]) -> None:
|
||||
"""Initialize the task method wrapper.
|
||||
|
||||
Args:
|
||||
meth: The method to wrap.
|
||||
"""
|
||||
self._meth = meth
|
||||
_copy_method_metadata(self, meth)
|
||||
|
||||
def ensure_task_name(self, result: TaskResultT) -> TaskResultT:
|
||||
"""Ensure task result has a name set.
|
||||
|
||||
Args:
|
||||
result: The task result to check.
|
||||
|
||||
Returns:
|
||||
The task result with name ensured.
|
||||
"""
|
||||
if not result.name:
|
||||
result.name = self._meth.__name__
|
||||
return result
|
||||
|
||||
def __get__(
|
||||
self, obj: Any, objtype: type[Any] | None = None
|
||||
) -> Self | BoundTaskMethod[TaskResultT]:
|
||||
"""Support instance methods by implementing the descriptor protocol.
|
||||
|
||||
Args:
|
||||
obj: The instance that the method is accessed through.
|
||||
objtype: The type of the instance.
|
||||
|
||||
Returns:
|
||||
Self when accessed through class, bound method when accessed through instance.
|
||||
"""
|
||||
if obj is None:
|
||||
return self
|
||||
return BoundTaskMethod(self, obj)
|
||||
|
||||
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> TaskResultT:
|
||||
"""Call the wrapped method and set task name if not provided.
|
||||
|
||||
Args:
|
||||
*args: Positional arguments.
|
||||
**kwargs: Keyword arguments.
|
||||
|
||||
Returns:
|
||||
The task instance with name set if not already provided.
|
||||
"""
|
||||
return self.ensure_task_name(self._meth(*args, **kwargs))
|
||||
|
||||
def unwrap(self) -> Callable[P, TaskResultT]:
|
||||
"""Get the original unwrapped method.
|
||||
|
||||
Returns:
|
||||
The original method before decoration.
|
||||
"""
|
||||
return self._meth
|
||||
|
||||
|
||||
class AgentMethod(DecoratedMethod[P, R]):
|
||||
"""Wrapper for methods marked as crew agents."""
|
||||
|
||||
is_agent: bool = True
|
||||
|
||||
|
||||
class LLMMethod(DecoratedMethod[P, R]):
|
||||
"""Wrapper for methods marked as LLM providers."""
|
||||
|
||||
is_llm: bool = True
|
||||
|
||||
|
||||
class ToolMethod(DecoratedMethod[P, R]):
|
||||
"""Wrapper for methods marked as crew tools."""
|
||||
|
||||
is_tool: bool = True
|
||||
|
||||
|
||||
class CallbackMethod(DecoratedMethod[P, R]):
|
||||
"""Wrapper for methods marked as crew callbacks."""
|
||||
|
||||
is_callback: bool = True
|
||||
|
||||
|
||||
class CacheHandlerMethod(DecoratedMethod[P, R]):
|
||||
"""Wrapper for methods marked as cache handlers."""
|
||||
|
||||
is_cache_handler: bool = True
|
||||
|
||||
|
||||
class CrewMethod(DecoratedMethod[P, R]):
|
||||
"""Wrapper for methods marked as the main crew execution point."""
|
||||
|
||||
is_crew: bool = True
|
||||
|
||||
|
||||
class OutputClass(Generic[T]):
|
||||
"""Base wrapper for classes marked as output format."""
|
||||
|
||||
def __init__(self, cls: type[T]) -> None:
|
||||
"""Initialize the output class wrapper.
|
||||
|
||||
Args:
|
||||
cls: The class to wrap.
|
||||
"""
|
||||
self._cls = cls
|
||||
self.__name__ = cls.__name__
|
||||
self.__qualname__ = cls.__qualname__
|
||||
self.__module__ = cls.__module__
|
||||
self.__doc__ = cls.__doc__
|
||||
|
||||
def __call__(self, *args: Any, **kwargs: Any) -> T:
|
||||
"""Create an instance of the wrapped class.
|
||||
|
||||
Args:
|
||||
*args: Positional arguments for the class constructor.
|
||||
**kwargs: Keyword arguments for the class constructor.
|
||||
|
||||
Returns:
|
||||
An instance of the wrapped class.
|
||||
"""
|
||||
return self._cls(*args, **kwargs)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
"""Delegate attribute access to the wrapped class.
|
||||
|
||||
Args:
|
||||
name: The attribute name.
|
||||
|
||||
Returns:
|
||||
The attribute from the wrapped class.
|
||||
"""
|
||||
return getattr(self._cls, name)
|
||||
|
||||
|
||||
class OutputJsonClass(OutputClass[T]):
|
||||
"""Wrapper for classes marked as JSON output format."""
|
||||
|
||||
is_output_json: bool = True
|
||||
|
||||
|
||||
class OutputPydanticClass(OutputClass[T]):
|
||||
"""Wrapper for classes marked as Pydantic output format."""
|
||||
|
||||
is_output_pydantic: bool = True
|
||||
@@ -1,13 +1,15 @@
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import Future
|
||||
from copy import copy as shallow_copy
|
||||
import datetime
|
||||
from hashlib import md5
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import threading
|
||||
import uuid
|
||||
import warnings
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import Future
|
||||
from copy import copy as shallow_copy
|
||||
from hashlib import md5
|
||||
from pathlib import Path
|
||||
from typing import (
|
||||
Any,
|
||||
ClassVar,
|
||||
@@ -15,8 +17,6 @@ from typing import (
|
||||
get_args,
|
||||
get_origin,
|
||||
)
|
||||
import uuid
|
||||
import warnings
|
||||
|
||||
from pydantic import (
|
||||
UUID4,
|
||||
@@ -42,16 +42,11 @@ from crewai.tools.base_tool import BaseTool
|
||||
from crewai.utilities.config import process_config
|
||||
from crewai.utilities.constants import NOT_SPECIFIED, _NotSpecified
|
||||
from crewai.utilities.converter import Converter, convert_to_model
|
||||
from crewai.utilities.guardrail import (
|
||||
GuardrailType,
|
||||
GuardrailsType,
|
||||
process_guardrail,
|
||||
)
|
||||
from crewai.utilities.guardrail import process_guardrail
|
||||
from crewai.utilities.i18n import I18N
|
||||
from crewai.utilities.printer import Printer
|
||||
from crewai.utilities.string_utils import interpolate_only
|
||||
|
||||
|
||||
_printer = Printer()
|
||||
|
||||
|
||||
@@ -155,15 +150,10 @@ class Task(BaseModel):
|
||||
default=None,
|
||||
)
|
||||
processed_by_agents: set[str] = Field(default_factory=set)
|
||||
guardrail: GuardrailType = Field(
|
||||
guardrail: Callable[[TaskOutput], tuple[bool, Any]] | str | None = Field(
|
||||
default=None,
|
||||
description="Function or string description of a guardrail to validate task output before proceeding to next task",
|
||||
)
|
||||
guardrails: GuardrailsType = Field(
|
||||
default=None,
|
||||
description="List of guardrails to validate task output before proceeding to next task. Also supports a single guardrail function or string description of a guardrail to validate task output before proceeding to next task",
|
||||
)
|
||||
|
||||
max_retries: int | None = Field(
|
||||
default=None,
|
||||
description="[DEPRECATED] Maximum number of retries when guardrail fails. Use guardrail_max_retries instead. Will be removed in v1.0.0",
|
||||
@@ -244,12 +234,6 @@ class Task(BaseModel):
|
||||
return v
|
||||
|
||||
_guardrail: Callable | None = PrivateAttr(default=None)
|
||||
_guardrails: list[Callable[[TaskOutput], tuple[bool, Any]] | str] = PrivateAttr(
|
||||
default=[]
|
||||
)
|
||||
_guardrail_retry_counts: dict[int, int] = PrivateAttr(
|
||||
default_factory=dict,
|
||||
)
|
||||
_original_description: str | None = PrivateAttr(default=None)
|
||||
_original_expected_output: str | None = PrivateAttr(default=None)
|
||||
_original_output_file: str | None = PrivateAttr(default=None)
|
||||
@@ -286,50 +270,6 @@ class Task(BaseModel):
|
||||
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def ensure_guardrails_is_list_of_callables(self) -> "Task":
|
||||
guardrails = []
|
||||
if self.guardrails is not None:
|
||||
if isinstance(self.guardrails, (list, tuple)):
|
||||
if len(self.guardrails) > 0:
|
||||
for guardrail in self.guardrails:
|
||||
if callable(guardrail):
|
||||
guardrails.append(guardrail)
|
||||
elif isinstance(guardrail, str):
|
||||
if self.agent is None:
|
||||
raise ValueError(
|
||||
"Agent is required to use non-programmatic guardrails"
|
||||
)
|
||||
from crewai.tasks.llm_guardrail import LLMGuardrail
|
||||
|
||||
guardrails.append(
|
||||
LLMGuardrail(description=guardrail, llm=self.agent.llm)
|
||||
)
|
||||
else:
|
||||
raise ValueError("Guardrail must be a callable or a string")
|
||||
else:
|
||||
if callable(self.guardrails):
|
||||
guardrails.append(self.guardrails)
|
||||
elif isinstance(self.guardrails, str):
|
||||
if self.agent is None:
|
||||
raise ValueError(
|
||||
"Agent is required to use non-programmatic guardrails"
|
||||
)
|
||||
from crewai.tasks.llm_guardrail import LLMGuardrail
|
||||
|
||||
guardrails.append(
|
||||
LLMGuardrail(description=self.guardrails, llm=self.agent.llm)
|
||||
)
|
||||
else:
|
||||
raise ValueError("Guardrail must be a callable or a string")
|
||||
|
||||
self._guardrails = guardrails
|
||||
if self._guardrails:
|
||||
self.guardrail = None
|
||||
self._guardrail = None
|
||||
|
||||
return self
|
||||
|
||||
@field_validator("id", mode="before")
|
||||
@classmethod
|
||||
def _deny_user_set_id(cls, v: UUID4 | None) -> None:
|
||||
@@ -518,24 +458,48 @@ class Task(BaseModel):
|
||||
output_format=self._get_output_format(),
|
||||
)
|
||||
|
||||
if self._guardrails:
|
||||
for idx, guardrail in enumerate(self._guardrails):
|
||||
task_output = self._invoke_guardrail_function(
|
||||
task_output=task_output,
|
||||
agent=agent,
|
||||
tools=tools,
|
||||
guardrail=guardrail,
|
||||
guardrail_index=idx,
|
||||
if self._guardrail:
|
||||
guardrail_result = process_guardrail(
|
||||
output=task_output,
|
||||
guardrail=self._guardrail,
|
||||
retry_count=self.retry_count,
|
||||
event_source=self,
|
||||
from_task=self,
|
||||
from_agent=agent,
|
||||
)
|
||||
if not guardrail_result.success:
|
||||
if self.retry_count >= self.guardrail_max_retries:
|
||||
raise Exception(
|
||||
f"Task failed guardrail validation after {self.guardrail_max_retries} retries. "
|
||||
f"Last error: {guardrail_result.error}"
|
||||
)
|
||||
|
||||
self.retry_count += 1
|
||||
context = self.i18n.errors("validation_error").format(
|
||||
guardrail_result_error=guardrail_result.error,
|
||||
task_output=task_output.raw,
|
||||
)
|
||||
printer = Printer()
|
||||
printer.print(
|
||||
content=f"Guardrail blocked, retrying, due to: {guardrail_result.error}\n",
|
||||
color="yellow",
|
||||
)
|
||||
return self._execute_core(agent, context, tools)
|
||||
|
||||
if guardrail_result.result is None:
|
||||
raise Exception(
|
||||
"Task guardrail returned None as result. This is not allowed."
|
||||
)
|
||||
|
||||
# backwards support
|
||||
if self._guardrail:
|
||||
task_output = self._invoke_guardrail_function(
|
||||
task_output=task_output,
|
||||
agent=agent,
|
||||
tools=tools,
|
||||
guardrail=self._guardrail,
|
||||
)
|
||||
if isinstance(guardrail_result.result, str):
|
||||
task_output.raw = guardrail_result.result
|
||||
pydantic_output, json_output = self._export_output(
|
||||
guardrail_result.result
|
||||
)
|
||||
task_output.pydantic = pydantic_output
|
||||
task_output.json_dict = json_output
|
||||
elif isinstance(guardrail_result.result, TaskOutput):
|
||||
task_output = guardrail_result.result
|
||||
|
||||
self.output = task_output
|
||||
self.end_time = datetime.datetime.now()
|
||||
@@ -664,10 +628,7 @@ Follow these guidelines:
|
||||
try:
|
||||
crew_chat_messages = json.loads(crew_chat_messages_json)
|
||||
except json.JSONDecodeError as e:
|
||||
_printer.print(
|
||||
f"An error occurred while parsing crew chat messages: {e}",
|
||||
color="red",
|
||||
)
|
||||
_printer.print(f"An error occurred while parsing crew chat messages: {e}", color="red")
|
||||
raise
|
||||
|
||||
conversation_history = "\n".join(
|
||||
@@ -830,101 +791,3 @@ Follow these guidelines:
|
||||
Fingerprint: The fingerprint of the task
|
||||
"""
|
||||
return self.security_config.fingerprint
|
||||
|
||||
def _invoke_guardrail_function(
|
||||
self,
|
||||
task_output: TaskOutput,
|
||||
agent: BaseAgent,
|
||||
tools: list[BaseTool],
|
||||
guardrail: Callable | None,
|
||||
guardrail_index: int | None = None,
|
||||
) -> TaskOutput:
|
||||
if not guardrail:
|
||||
return task_output
|
||||
|
||||
if guardrail_index is not None:
|
||||
current_retry_count = self._guardrail_retry_counts.get(guardrail_index, 0)
|
||||
else:
|
||||
current_retry_count = self.retry_count
|
||||
|
||||
max_attempts = self.guardrail_max_retries + 1
|
||||
|
||||
for attempt in range(max_attempts):
|
||||
guardrail_result = process_guardrail(
|
||||
output=task_output,
|
||||
guardrail=guardrail,
|
||||
retry_count=current_retry_count,
|
||||
event_source=self,
|
||||
from_task=self,
|
||||
from_agent=agent,
|
||||
)
|
||||
|
||||
if guardrail_result.success:
|
||||
# Guardrail passed
|
||||
if guardrail_result.result is None:
|
||||
raise Exception(
|
||||
"Task guardrail returned None as result. This is not allowed."
|
||||
)
|
||||
|
||||
if isinstance(guardrail_result.result, str):
|
||||
task_output.raw = guardrail_result.result
|
||||
pydantic_output, json_output = self._export_output(
|
||||
guardrail_result.result
|
||||
)
|
||||
task_output.pydantic = pydantic_output
|
||||
task_output.json_dict = json_output
|
||||
elif isinstance(guardrail_result.result, TaskOutput):
|
||||
task_output = guardrail_result.result
|
||||
|
||||
return task_output
|
||||
|
||||
# Guardrail failed
|
||||
if attempt >= self.guardrail_max_retries:
|
||||
# Max retries reached
|
||||
guardrail_name = (
|
||||
f"guardrail {guardrail_index}"
|
||||
if guardrail_index is not None
|
||||
else "guardrail"
|
||||
)
|
||||
raise Exception(
|
||||
f"Task failed {guardrail_name} validation after {self.guardrail_max_retries} retries. "
|
||||
f"Last error: {guardrail_result.error}"
|
||||
)
|
||||
|
||||
if guardrail_index is not None:
|
||||
current_retry_count += 1
|
||||
self._guardrail_retry_counts[guardrail_index] = current_retry_count
|
||||
else:
|
||||
self.retry_count += 1
|
||||
current_retry_count = self.retry_count
|
||||
|
||||
context = self.i18n.errors("validation_error").format(
|
||||
guardrail_result_error=guardrail_result.error,
|
||||
task_output=task_output.raw,
|
||||
)
|
||||
printer = Printer()
|
||||
printer.print(
|
||||
content=f"Guardrail {guardrail_index if guardrail_index is not None else ''} blocked (attempt {attempt + 1}/{max_attempts}), retrying due to: {guardrail_result.error}\n",
|
||||
color="yellow",
|
||||
)
|
||||
|
||||
# Regenerate output from agent
|
||||
result = agent.execute_task(
|
||||
task=self,
|
||||
context=context,
|
||||
tools=tools,
|
||||
)
|
||||
|
||||
pydantic_output, json_output = self._export_output(result)
|
||||
task_output = TaskOutput(
|
||||
name=self.name or self.description,
|
||||
description=self.description,
|
||||
expected_output=self.expected_output,
|
||||
raw=result,
|
||||
pydantic=pydantic_output,
|
||||
json_dict=json_output,
|
||||
agent=agent.role,
|
||||
output_format=self._get_output_format(),
|
||||
)
|
||||
|
||||
return task_output
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
from typing import TYPE_CHECKING, Any, TypeAlias
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from typing_extensions import Self
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from crewai.agents.agent_builder.base_agent import BaseAgent
|
||||
from crewai.lite_agent import LiteAgent, LiteAgentOutput
|
||||
from crewai.task import Task
|
||||
from crewai.tasks.task_output import TaskOutput
|
||||
|
||||
GuardrailType: TypeAlias = Callable[["TaskOutput"], tuple[bool, Any]] | str | None
|
||||
|
||||
GuardrailsType: TypeAlias = Sequence[GuardrailType] | GuardrailType
|
||||
|
||||
|
||||
class GuardrailResult(BaseModel):
|
||||
"""Result from a task guardrail execution.
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
"""Utility for colored console output."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Final, Literal, NamedTuple
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from _typeshed import SupportsWrite
|
||||
from typing import Final, Literal, NamedTuple
|
||||
|
||||
PrinterColor = Literal[
|
||||
"purple",
|
||||
@@ -59,22 +54,13 @@ class Printer:
|
||||
|
||||
@staticmethod
|
||||
def print(
|
||||
content: str | list[ColoredText],
|
||||
color: PrinterColor | None = None,
|
||||
sep: str | None = " ",
|
||||
end: str | None = "\n",
|
||||
file: SupportsWrite[str] | None = None,
|
||||
flush: Literal[False] = False,
|
||||
content: str | list[ColoredText], color: PrinterColor | None = None
|
||||
) -> None:
|
||||
"""Prints content to the console with optional color formatting.
|
||||
|
||||
Args:
|
||||
content: Either a string or a list of ColoredText objects for multicolor output.
|
||||
color: Optional color for the text when content is a string. Ignored when content is a list.
|
||||
sep: Separator to use between the text and color.
|
||||
end: String appended after the last value.
|
||||
file: A file-like object (stream); defaults to the current sys.stdout.
|
||||
flush: Whether to forcibly flush the stream.
|
||||
"""
|
||||
if isinstance(content, str):
|
||||
content = [ColoredText(content, color)]
|
||||
@@ -82,9 +68,5 @@ class Printer:
|
||||
"".join(
|
||||
f"{_COLOR_CODES[c.color] if c.color else ''}{c.text}{RESET}"
|
||||
for c in content
|
||||
),
|
||||
sep=sep,
|
||||
end=end,
|
||||
file=file,
|
||||
flush=flush,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -120,105 +120,4 @@ interactions:
|
||||
status:
|
||||
code: 200
|
||||
message: OK
|
||||
- request:
|
||||
body: '{"trace_id": "b0237c14-8cd1-4453-920d-608a63d4b7ef", "execution_type":
|
||||
"crew", "user_identifier": null, "execution_context": {"crew_fingerprint": null,
|
||||
"crew_name": "crew", "flow_name": null, "crewai_version": "1.0.0b2", "privacy_level":
|
||||
"standard"}, "execution_metadata": {"expected_duration_estimate": 300, "agent_count":
|
||||
0, "task_count": 0, "flow_method_count": 0, "execution_started_at": "2025-10-18T15:21:00.300365+00:00"},
|
||||
"ephemeral_trace_id": "b0237c14-8cd1-4453-920d-608a63d4b7ef"}'
|
||||
headers:
|
||||
Accept:
|
||||
- '*/*'
|
||||
Accept-Encoding:
|
||||
- gzip, deflate, zstd
|
||||
Connection:
|
||||
- keep-alive
|
||||
Content-Length:
|
||||
- '490'
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- CrewAI-CLI/1.0.0b2
|
||||
X-Crewai-Organization-Id:
|
||||
- 60577da1-895c-4675-8135-62e9010bdcf3
|
||||
X-Crewai-Version:
|
||||
- 1.0.0b2
|
||||
method: POST
|
||||
uri: https://app.crewai.com/crewai_plus/api/v1/tracing/ephemeral/batches
|
||||
response:
|
||||
body:
|
||||
string: '{"id":"703e1e1b-7cca-4cc6-9d03-95d5ab7461e2","ephemeral_trace_id":"b0237c14-8cd1-4453-920d-608a63d4b7ef","execution_type":"crew","crew_name":"crew","flow_name":null,"status":"running","duration_ms":null,"crewai_version":"1.0.0b2","total_events":0,"execution_context":{"crew_fingerprint":null,"crew_name":"crew","flow_name":null,"crewai_version":"1.0.0b2","privacy_level":"standard"},"created_at":"2025-10-18T15:21:01.551Z","updated_at":"2025-10-18T15:21:01.551Z","access_code":"TRACE-91322fd9f9","user_identifier":null}'
|
||||
headers:
|
||||
Connection:
|
||||
- keep-alive
|
||||
Content-Length:
|
||||
- '519'
|
||||
Content-Type:
|
||||
- application/json; charset=utf-8
|
||||
Date:
|
||||
- Sat, 18 Oct 2025 15:21:01 GMT
|
||||
cache-control:
|
||||
- no-store
|
||||
content-security-policy:
|
||||
- 'default-src ''self'' *.app.crewai.com app.crewai.com; script-src ''self''
|
||||
''unsafe-inline'' *.app.crewai.com app.crewai.com https://cdn.jsdelivr.net/npm/apexcharts
|
||||
https://www.gstatic.com https://run.pstmn.io https://apis.google.com https://apis.google.com/js/api.js
|
||||
https://accounts.google.com https://accounts.google.com/gsi/client https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css.map
|
||||
https://*.google.com https://docs.google.com https://slides.google.com https://js.hs-scripts.com
|
||||
https://js.sentry-cdn.com https://browser.sentry-cdn.com https://www.googletagmanager.com
|
||||
https://js-na1.hs-scripts.com https://js.hubspot.com http://js-na1.hs-scripts.com
|
||||
https://bat.bing.com https://cdn.amplitude.com https://cdn.segment.com https://d1d3n03t5zntha.cloudfront.net/
|
||||
https://descriptusercontent.com https://edge.fullstory.com https://googleads.g.doubleclick.net
|
||||
https://js.hs-analytics.net https://js.hs-banner.com https://js.hsadspixel.net
|
||||
https://js.hscollectedforms.net https://js.usemessages.com https://snap.licdn.com
|
||||
https://static.cloudflareinsights.com https://static.reo.dev https://www.google-analytics.com
|
||||
https://share.descript.com/; style-src ''self'' ''unsafe-inline'' *.app.crewai.com
|
||||
app.crewai.com https://cdn.jsdelivr.net/npm/apexcharts; img-src ''self'' data:
|
||||
*.app.crewai.com app.crewai.com https://zeus.tools.crewai.com https://dashboard.tools.crewai.com
|
||||
https://cdn.jsdelivr.net https://forms.hsforms.com https://track.hubspot.com
|
||||
https://px.ads.linkedin.com https://px4.ads.linkedin.com https://www.google.com
|
||||
https://www.google.com.br; font-src ''self'' data: *.app.crewai.com app.crewai.com;
|
||||
connect-src ''self'' *.app.crewai.com app.crewai.com https://zeus.tools.crewai.com
|
||||
https://connect.useparagon.com/ https://zeus.useparagon.com/* https://*.useparagon.com/*
|
||||
https://run.pstmn.io https://connect.tools.crewai.com/ https://*.sentry.io
|
||||
https://www.google-analytics.com https://edge.fullstory.com https://rs.fullstory.com
|
||||
https://api.hubspot.com https://forms.hscollectedforms.net https://api.hubapi.com
|
||||
https://px.ads.linkedin.com https://px4.ads.linkedin.com https://google.com/pagead/form-data/16713662509
|
||||
https://google.com/ccm/form-data/16713662509 https://www.google.com/ccm/collect
|
||||
https://worker-actionkit.tools.crewai.com https://api.reo.dev; frame-src ''self''
|
||||
*.app.crewai.com app.crewai.com https://connect.useparagon.com/ https://zeus.tools.crewai.com
|
||||
https://zeus.useparagon.com/* https://connect.tools.crewai.com/ https://docs.google.com
|
||||
https://drive.google.com https://slides.google.com https://accounts.google.com
|
||||
https://*.google.com https://app.hubspot.com/ https://td.doubleclick.net https://www.googletagmanager.com/
|
||||
https://www.youtube.com https://share.descript.com'
|
||||
etag:
|
||||
- W/"9e9becfaa0607314159093ffcadb0713"
|
||||
expires:
|
||||
- '0'
|
||||
permissions-policy:
|
||||
- camera=(), microphone=(self), geolocation=()
|
||||
pragma:
|
||||
- no-cache
|
||||
referrer-policy:
|
||||
- strict-origin-when-cross-origin
|
||||
strict-transport-security:
|
||||
- max-age=63072000; includeSubDomains
|
||||
vary:
|
||||
- Accept
|
||||
x-content-type-options:
|
||||
- nosniff
|
||||
x-frame-options:
|
||||
- SAMEORIGIN
|
||||
x-permitted-cross-domain-policies:
|
||||
- none
|
||||
x-request-id:
|
||||
- 7dd520cd-8e74-4648-968b-90b1dc2e81d8
|
||||
x-runtime:
|
||||
- '0.099253'
|
||||
x-xss-protection:
|
||||
- 1; mode=block
|
||||
status:
|
||||
code: 201
|
||||
message: Created
|
||||
version: 1
|
||||
|
||||
@@ -160,13 +160,11 @@ def mock_opentelemetry_components():
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_event_bus_handlers(setup_test_environment):
|
||||
def clear_event_bus_handlers():
|
||||
"""Clear event bus handlers after each test for isolation.
|
||||
|
||||
Handlers registered during the test are allowed to run, then cleaned up
|
||||
after the test completes.
|
||||
|
||||
Depends on setup_test_environment to ensure cleanup happens in correct order.
|
||||
"""
|
||||
from crewai.events.event_bus import crewai_event_bus
|
||||
from crewai.experimental.evaluation.evaluation_listener import (
|
||||
@@ -175,7 +173,6 @@ def clear_event_bus_handlers(setup_test_environment):
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown event bus and wait for all handlers to complete
|
||||
crewai_event_bus.shutdown(wait=True)
|
||||
crewai_event_bus._initialize()
|
||||
|
||||
|
||||
@@ -141,10 +141,9 @@ def test_anthropic_completion_module_is_imported():
|
||||
assert hasattr(completion_mod, 'AnthropicCompletion')
|
||||
|
||||
|
||||
def test_native_anthropic_raises_error_when_initialization_fails():
|
||||
def test_fallback_to_litellm_when_native_anthropic_fails():
|
||||
"""
|
||||
Test that LLM raises ImportError when native Anthropic completion fails to initialize.
|
||||
This ensures we don't silently fall back when there's a configuration issue.
|
||||
Test that LLM falls back to LiteLLM when native Anthropic completion fails
|
||||
"""
|
||||
# Mock the _get_native_provider to return a failing class
|
||||
with patch('crewai.llm.LLM._get_native_provider') as mock_get_provider:
|
||||
@@ -155,12 +154,12 @@ def test_native_anthropic_raises_error_when_initialization_fails():
|
||||
|
||||
mock_get_provider.return_value = FailingCompletion
|
||||
|
||||
# This should raise ImportError, not fall back to LiteLLM
|
||||
with pytest.raises(ImportError) as excinfo:
|
||||
LLM(model="anthropic/claude-3-5-sonnet-20241022")
|
||||
# This should fall back to LiteLLM
|
||||
llm = LLM(model="anthropic/claude-3-5-sonnet-20241022")
|
||||
|
||||
assert "Error importing native provider" in str(excinfo.value)
|
||||
assert "Native Anthropic SDK failed" in str(excinfo.value)
|
||||
# Check that it's using LiteLLM
|
||||
assert hasattr(llm, 'is_litellm')
|
||||
assert llm.is_litellm == True
|
||||
|
||||
|
||||
def test_anthropic_completion_initialization_parameters():
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# Azure LLM tests
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,738 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from unittest.mock import patch, MagicMock
|
||||
import pytest
|
||||
|
||||
from crewai.llm import LLM
|
||||
from crewai.crew import Crew
|
||||
from crewai.agent import Agent
|
||||
from crewai.task import Task
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_aws_credentials():
|
||||
"""Automatically mock AWS credentials and boto3 Session for all tests in this module."""
|
||||
with patch.dict(os.environ, {
|
||||
"AWS_ACCESS_KEY_ID": "test-access-key",
|
||||
"AWS_SECRET_ACCESS_KEY": "test-secret-key",
|
||||
"AWS_DEFAULT_REGION": "us-east-1"
|
||||
}):
|
||||
# Mock boto3 Session to prevent actual AWS connections
|
||||
with patch('crewai.llms.providers.bedrock.completion.Session') as mock_session_class:
|
||||
# Create mock session instance
|
||||
mock_session_instance = MagicMock()
|
||||
mock_client = MagicMock()
|
||||
|
||||
# Set up default mock responses to prevent hanging
|
||||
default_response = {
|
||||
'output': {
|
||||
'message': {
|
||||
'role': 'assistant',
|
||||
'content': [
|
||||
{'text': 'Test response'}
|
||||
]
|
||||
}
|
||||
},
|
||||
'usage': {
|
||||
'inputTokens': 10,
|
||||
'outputTokens': 5,
|
||||
'totalTokens': 15
|
||||
}
|
||||
}
|
||||
mock_client.converse.return_value = default_response
|
||||
mock_client.converse_stream.return_value = {'stream': []}
|
||||
|
||||
# Configure the mock session instance to return the mock client
|
||||
mock_session_instance.client.return_value = mock_client
|
||||
|
||||
# Configure the mock Session class to return the mock session instance
|
||||
mock_session_class.return_value = mock_session_instance
|
||||
|
||||
yield mock_session_class, mock_client
|
||||
|
||||
|
||||
def test_bedrock_completion_is_used_when_bedrock_provider():
|
||||
"""
|
||||
Test that BedrockCompletion from completion.py is used when LLM uses provider 'bedrock'
|
||||
"""
|
||||
llm = LLM(model="bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0")
|
||||
|
||||
assert llm.__class__.__name__ == "BedrockCompletion"
|
||||
assert llm.provider == "bedrock"
|
||||
assert llm.model == "anthropic.claude-3-5-sonnet-20241022-v2:0"
|
||||
|
||||
|
||||
def test_bedrock_completion_module_is_imported():
|
||||
"""
|
||||
Test that the completion module is properly imported when using Bedrock provider
|
||||
"""
|
||||
module_name = "crewai.llms.providers.bedrock.completion"
|
||||
|
||||
# Remove module from cache if it exists
|
||||
if module_name in sys.modules:
|
||||
del sys.modules[module_name]
|
||||
|
||||
# Create LLM instance - this should trigger the import
|
||||
LLM(model="bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0")
|
||||
|
||||
# Verify the module was imported
|
||||
assert module_name in sys.modules
|
||||
completion_mod = sys.modules[module_name]
|
||||
assert isinstance(completion_mod, types.ModuleType)
|
||||
|
||||
# Verify the class exists in the module
|
||||
assert hasattr(completion_mod, 'BedrockCompletion')
|
||||
|
||||
|
||||
def test_native_bedrock_raises_error_when_initialization_fails():
|
||||
"""
|
||||
Test that LLM raises ImportError when native Bedrock completion fails.
|
||||
|
||||
With the new behavior, when a native provider is in SUPPORTED_NATIVE_PROVIDERS
|
||||
but fails to instantiate, we raise an ImportError instead of silently falling back.
|
||||
This provides clearer error messages to users about missing dependencies.
|
||||
"""
|
||||
# Mock the _get_native_provider to return a failing class
|
||||
with patch('crewai.llm.LLM._get_native_provider') as mock_get_provider:
|
||||
|
||||
class FailingCompletion:
|
||||
def __init__(self, *args, **kwargs):
|
||||
raise Exception("Native AWS Bedrock SDK failed")
|
||||
|
||||
mock_get_provider.return_value = FailingCompletion
|
||||
|
||||
# This should raise ImportError with clear message
|
||||
with pytest.raises(ImportError) as excinfo:
|
||||
LLM(model="bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0")
|
||||
|
||||
# Verify the error message is helpful
|
||||
assert "Error importing native provider" in str(excinfo.value)
|
||||
assert "Native AWS Bedrock SDK failed" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_bedrock_completion_initialization_parameters():
|
||||
"""
|
||||
Test that BedrockCompletion is initialized with correct parameters
|
||||
"""
|
||||
llm = LLM(
|
||||
model="bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
temperature=0.7,
|
||||
max_tokens=2000,
|
||||
top_p=0.9,
|
||||
top_k=40,
|
||||
region_name="us-west-2"
|
||||
)
|
||||
|
||||
from crewai.llms.providers.bedrock.completion import BedrockCompletion
|
||||
assert isinstance(llm, BedrockCompletion)
|
||||
assert llm.model == "anthropic.claude-3-5-sonnet-20241022-v2:0"
|
||||
assert llm.temperature == 0.7
|
||||
assert llm.max_tokens == 2000
|
||||
assert llm.top_p == 0.9
|
||||
assert llm.top_k == 40
|
||||
assert llm.region_name == "us-west-2"
|
||||
|
||||
|
||||
def test_bedrock_specific_parameters():
|
||||
"""
|
||||
Test Bedrock-specific parameters like stop_sequences and streaming
|
||||
"""
|
||||
llm = LLM(
|
||||
model="bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
stop_sequences=["Human:", "Assistant:"],
|
||||
stream=True,
|
||||
region_name="us-east-1"
|
||||
)
|
||||
|
||||
from crewai.llms.providers.bedrock.completion import BedrockCompletion
|
||||
assert isinstance(llm, BedrockCompletion)
|
||||
assert llm.stop_sequences == ["Human:", "Assistant:"]
|
||||
assert llm.stream == True
|
||||
assert llm.region_name == "us-east-1"
|
||||
|
||||
|
||||
def test_bedrock_completion_call():
|
||||
"""
|
||||
Test that BedrockCompletion call method works
|
||||
"""
|
||||
llm = LLM(model="bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0")
|
||||
|
||||
# Mock the call method on the instance
|
||||
with patch.object(llm, 'call', return_value="Hello! I'm Claude on Bedrock, ready to help.") as mock_call:
|
||||
result = llm.call("Hello, how are you?")
|
||||
|
||||
assert result == "Hello! I'm Claude on Bedrock, ready to help."
|
||||
mock_call.assert_called_once_with("Hello, how are you?")
|
||||
|
||||
|
||||
def test_bedrock_completion_called_during_crew_execution():
|
||||
"""
|
||||
Test that BedrockCompletion.call is actually invoked when running a crew
|
||||
"""
|
||||
# Create the LLM instance first
|
||||
bedrock_llm = LLM(model="bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0")
|
||||
|
||||
# Mock the call method on the specific instance
|
||||
with patch.object(bedrock_llm, 'call', return_value="Tokyo has 14 million people.") as mock_call:
|
||||
|
||||
# Create agent with explicit LLM configuration
|
||||
agent = Agent(
|
||||
role="Research Assistant",
|
||||
goal="Find population info",
|
||||
backstory="You research populations.",
|
||||
llm=bedrock_llm,
|
||||
)
|
||||
|
||||
task = Task(
|
||||
description="Find Tokyo population",
|
||||
expected_output="Population number",
|
||||
agent=agent,
|
||||
)
|
||||
|
||||
crew = Crew(agents=[agent], tasks=[task])
|
||||
result = crew.kickoff()
|
||||
|
||||
# Verify mock was called
|
||||
assert mock_call.called
|
||||
assert "14 million" in str(result)
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Crew execution test - may hang, needs investigation")
|
||||
def test_bedrock_completion_call_arguments():
|
||||
"""
|
||||
Test that BedrockCompletion.call is invoked with correct arguments
|
||||
"""
|
||||
# Create LLM instance first
|
||||
bedrock_llm = LLM(model="bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0")
|
||||
|
||||
# Mock the instance method
|
||||
with patch.object(bedrock_llm, 'call') as mock_call:
|
||||
mock_call.return_value = "Task completed successfully."
|
||||
|
||||
agent = Agent(
|
||||
role="Test Agent",
|
||||
goal="Complete a simple task",
|
||||
backstory="You are a test agent.",
|
||||
llm=bedrock_llm # Use same instance
|
||||
)
|
||||
|
||||
task = Task(
|
||||
description="Say hello world",
|
||||
expected_output="Hello world",
|
||||
agent=agent,
|
||||
)
|
||||
|
||||
crew = Crew(agents=[agent], tasks=[task])
|
||||
crew.kickoff()
|
||||
|
||||
# Verify call was made
|
||||
assert mock_call.called
|
||||
|
||||
# Check the arguments passed to the call method
|
||||
call_args = mock_call.call_args
|
||||
assert call_args is not None
|
||||
|
||||
# The first argument should be the messages
|
||||
messages = call_args[0][0] # First positional argument
|
||||
assert isinstance(messages, (str, list))
|
||||
|
||||
# Verify that the task description appears in the messages
|
||||
if isinstance(messages, str):
|
||||
assert "hello world" in messages.lower()
|
||||
elif isinstance(messages, list):
|
||||
message_content = str(messages).lower()
|
||||
assert "hello world" in message_content
|
||||
|
||||
|
||||
def test_multiple_bedrock_calls_in_crew():
|
||||
"""
|
||||
Test that BedrockCompletion.call is invoked multiple times for multiple tasks
|
||||
"""
|
||||
# Create LLM instance first
|
||||
bedrock_llm = LLM(model="bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0")
|
||||
|
||||
# Mock the instance method
|
||||
with patch.object(bedrock_llm, 'call') as mock_call:
|
||||
mock_call.return_value = "Task completed."
|
||||
|
||||
agent = Agent(
|
||||
role="Multi-task Agent",
|
||||
goal="Complete multiple tasks",
|
||||
backstory="You can handle multiple tasks.",
|
||||
llm=bedrock_llm # Use same instance
|
||||
)
|
||||
|
||||
task1 = Task(
|
||||
description="First task",
|
||||
expected_output="First result",
|
||||
agent=agent,
|
||||
)
|
||||
|
||||
task2 = Task(
|
||||
description="Second task",
|
||||
expected_output="Second result",
|
||||
agent=agent,
|
||||
)
|
||||
|
||||
crew = Crew(
|
||||
agents=[agent],
|
||||
tasks=[task1, task2]
|
||||
)
|
||||
crew.kickoff()
|
||||
|
||||
# Verify multiple calls were made
|
||||
assert mock_call.call_count >= 2 # At least one call per task
|
||||
|
||||
# Verify each call had proper arguments
|
||||
for call in mock_call.call_args_list:
|
||||
assert len(call[0]) > 0 # Has positional arguments
|
||||
messages = call[0][0]
|
||||
assert messages is not None
|
||||
|
||||
def test_bedrock_completion_with_tools():
|
||||
"""
|
||||
Test that BedrockCompletion.call is invoked with tools when agent has tools
|
||||
"""
|
||||
from crewai.tools import tool
|
||||
|
||||
@tool
|
||||
def sample_tool(query: str) -> str:
|
||||
"""A sample tool for testing"""
|
||||
return f"Tool result for: {query}"
|
||||
|
||||
# Create LLM instance first
|
||||
bedrock_llm = LLM(model="bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0")
|
||||
|
||||
# Mock the instance method
|
||||
with patch.object(bedrock_llm, 'call') as mock_call:
|
||||
mock_call.return_value = "Task completed with tools."
|
||||
|
||||
agent = Agent(
|
||||
role="Tool User",
|
||||
goal="Use tools to complete tasks",
|
||||
backstory="You can use tools.",
|
||||
llm=bedrock_llm, # Use same instance
|
||||
tools=[sample_tool]
|
||||
)
|
||||
|
||||
task = Task(
|
||||
description="Use the sample tool",
|
||||
expected_output="Tool usage result",
|
||||
agent=agent,
|
||||
)
|
||||
|
||||
crew = Crew(agents=[agent], tasks=[task])
|
||||
|
||||
crew.kickoff()
|
||||
|
||||
assert mock_call.called
|
||||
|
||||
call_args = mock_call.call_args
|
||||
call_kwargs = call_args[1] if len(call_args) > 1 else {}
|
||||
|
||||
if 'tools' in call_kwargs:
|
||||
assert call_kwargs['tools'] is not None
|
||||
assert len(call_kwargs['tools']) > 0
|
||||
|
||||
|
||||
def test_bedrock_raises_error_when_model_not_found(mock_aws_credentials):
|
||||
"""Test that BedrockCompletion raises appropriate error when model not found"""
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
# Get the mock client from the fixture
|
||||
_, mock_client = mock_aws_credentials
|
||||
|
||||
error_response = {
|
||||
'Error': {
|
||||
'Code': 'ResourceNotFoundException',
|
||||
'Message': 'Could not resolve the foundation model from the model identifier'
|
||||
}
|
||||
}
|
||||
mock_client.converse.side_effect = ClientError(error_response, 'converse')
|
||||
|
||||
llm = LLM(model="bedrock/model-doesnt-exist")
|
||||
|
||||
with pytest.raises(Exception): # Should raise some error for unsupported model
|
||||
llm.call("Hello")
|
||||
|
||||
|
||||
def test_bedrock_aws_credentials_configuration():
|
||||
"""
|
||||
Test that AWS credentials configuration works properly
|
||||
"""
|
||||
# Test with environment variables
|
||||
with patch.dict(os.environ, {
|
||||
"AWS_ACCESS_KEY_ID": "test-access-key",
|
||||
"AWS_SECRET_ACCESS_KEY": "test-secret-key",
|
||||
"AWS_DEFAULT_REGION": "us-east-1"
|
||||
}):
|
||||
llm = LLM(model="bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0")
|
||||
|
||||
from crewai.llms.providers.bedrock.completion import BedrockCompletion
|
||||
assert isinstance(llm, BedrockCompletion)
|
||||
assert llm.region_name == "us-east-1"
|
||||
|
||||
# Test with explicit credentials
|
||||
llm_explicit = LLM(
|
||||
model="bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
aws_access_key_id="explicit-key",
|
||||
aws_secret_access_key="explicit-secret",
|
||||
region_name="us-west-2"
|
||||
)
|
||||
assert isinstance(llm_explicit, BedrockCompletion)
|
||||
assert llm_explicit.region_name == "us-west-2"
|
||||
|
||||
|
||||
def test_bedrock_model_capabilities():
|
||||
"""
|
||||
Test that model capabilities are correctly identified
|
||||
"""
|
||||
# Test Claude model
|
||||
llm_claude = LLM(model="bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0")
|
||||
from crewai.llms.providers.bedrock.completion import BedrockCompletion
|
||||
assert isinstance(llm_claude, BedrockCompletion)
|
||||
assert llm_claude.is_claude_model == True
|
||||
assert llm_claude.supports_tools == True
|
||||
|
||||
# Test other Bedrock model
|
||||
llm_titan = LLM(model="bedrock/amazon.titan-text-express-v1")
|
||||
assert isinstance(llm_titan, BedrockCompletion)
|
||||
assert llm_titan.supports_tools == True
|
||||
|
||||
|
||||
def test_bedrock_inference_config():
|
||||
"""
|
||||
Test that inference config is properly prepared
|
||||
"""
|
||||
llm = LLM(
|
||||
model="bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
temperature=0.7,
|
||||
top_p=0.9,
|
||||
top_k=40,
|
||||
max_tokens=1000
|
||||
)
|
||||
|
||||
from crewai.llms.providers.bedrock.completion import BedrockCompletion
|
||||
assert isinstance(llm, BedrockCompletion)
|
||||
|
||||
# Test config preparation
|
||||
config = llm._get_inference_config()
|
||||
|
||||
# Verify config has the expected parameters
|
||||
assert 'temperature' in config
|
||||
assert config['temperature'] == 0.7
|
||||
assert 'topP' in config
|
||||
assert config['topP'] == 0.9
|
||||
assert 'maxTokens' in config
|
||||
assert config['maxTokens'] == 1000
|
||||
assert 'topK' in config
|
||||
assert config['topK'] == 40
|
||||
|
||||
|
||||
def test_bedrock_model_detection():
|
||||
"""
|
||||
Test that various Bedrock model formats are properly detected
|
||||
"""
|
||||
# Test Bedrock model naming patterns
|
||||
bedrock_test_cases = [
|
||||
"bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
"bedrock/anthropic.claude-3-haiku-20240307-v1:0",
|
||||
"bedrock/amazon.titan-text-express-v1",
|
||||
"bedrock/meta.llama3-70b-instruct-v1:0"
|
||||
]
|
||||
|
||||
for model_name in bedrock_test_cases:
|
||||
llm = LLM(model=model_name)
|
||||
from crewai.llms.providers.bedrock.completion import BedrockCompletion
|
||||
assert isinstance(llm, BedrockCompletion), f"Failed for model: {model_name}"
|
||||
|
||||
|
||||
def test_bedrock_supports_stop_words():
|
||||
"""
|
||||
Test that Bedrock models support stop sequences
|
||||
"""
|
||||
llm = LLM(model="bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0")
|
||||
assert llm.supports_stop_words() == True
|
||||
|
||||
|
||||
def test_bedrock_context_window_size():
|
||||
"""
|
||||
Test that Bedrock models return correct context window sizes
|
||||
"""
|
||||
# Test Claude 3.5 Sonnet
|
||||
llm_claude = LLM(model="bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0")
|
||||
context_size_claude = llm_claude.get_context_window_size()
|
||||
assert context_size_claude > 150000 # Should be substantial (200K tokens with ratio)
|
||||
|
||||
# Test Titan
|
||||
llm_titan = LLM(model="bedrock/amazon.titan-text-express-v1")
|
||||
context_size_titan = llm_titan.get_context_window_size()
|
||||
assert context_size_titan > 5000 # Should have 8K context window
|
||||
|
||||
|
||||
def test_bedrock_message_formatting():
|
||||
"""
|
||||
Test that messages are properly formatted for Bedrock Converse API
|
||||
"""
|
||||
llm = LLM(model="bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0")
|
||||
|
||||
# Test message formatting
|
||||
test_messages = [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi there!"},
|
||||
{"role": "user", "content": "How are you?"}
|
||||
]
|
||||
|
||||
formatted_messages, system_message = llm._format_messages_for_converse(test_messages)
|
||||
|
||||
# System message should be extracted
|
||||
assert system_message == "You are a helpful assistant."
|
||||
|
||||
# Remaining messages should be in Converse format
|
||||
assert len(formatted_messages) >= 3 # Should have user, assistant, user messages
|
||||
|
||||
# First message should be user role
|
||||
assert formatted_messages[0]["role"] == "user"
|
||||
# Second should be assistant
|
||||
assert formatted_messages[1]["role"] == "assistant"
|
||||
|
||||
# Messages should have content array with text
|
||||
assert isinstance(formatted_messages[0]["content"], list)
|
||||
assert "text" in formatted_messages[0]["content"][0]
|
||||
|
||||
|
||||
def test_bedrock_streaming_parameter():
|
||||
"""
|
||||
Test that streaming parameter is properly handled
|
||||
"""
|
||||
# Test non-streaming
|
||||
llm_no_stream = LLM(model="bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0", stream=False)
|
||||
assert llm_no_stream.stream == False
|
||||
|
||||
# Test streaming
|
||||
llm_stream = LLM(model="bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0", stream=True)
|
||||
assert llm_stream.stream == True
|
||||
|
||||
|
||||
def test_bedrock_tool_conversion():
|
||||
"""
|
||||
Test that tools are properly converted to Bedrock Converse format
|
||||
"""
|
||||
llm = LLM(model="bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0")
|
||||
|
||||
# Mock tool in CrewAI format
|
||||
crewai_tools = [{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "test_tool",
|
||||
"description": "A test tool",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Search query"}
|
||||
},
|
||||
"required": ["query"]
|
||||
}
|
||||
}
|
||||
}]
|
||||
|
||||
# Test tool conversion
|
||||
bedrock_tools = llm._format_tools_for_converse(crewai_tools)
|
||||
|
||||
assert len(bedrock_tools) == 1
|
||||
# Bedrock tools should have toolSpec structure
|
||||
assert "toolSpec" in bedrock_tools[0]
|
||||
assert bedrock_tools[0]["toolSpec"]["name"] == "test_tool"
|
||||
assert bedrock_tools[0]["toolSpec"]["description"] == "A test tool"
|
||||
assert "inputSchema" in bedrock_tools[0]["toolSpec"]
|
||||
|
||||
|
||||
def test_bedrock_environment_variable_credentials(mock_aws_credentials):
|
||||
"""
|
||||
Test that AWS credentials are properly loaded from environment
|
||||
"""
|
||||
mock_session_class, _ = mock_aws_credentials
|
||||
|
||||
# Reset the mock to clear any previous calls
|
||||
mock_session_class.reset_mock()
|
||||
|
||||
with patch.dict(os.environ, {
|
||||
"AWS_ACCESS_KEY_ID": "test-access-key-123",
|
||||
"AWS_SECRET_ACCESS_KEY": "test-secret-key-456"
|
||||
}):
|
||||
llm = LLM(model="bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0")
|
||||
|
||||
# Verify Session was called with environment credentials
|
||||
assert mock_session_class.called
|
||||
# Get the most recent call - Session is called as Session(...)
|
||||
call_kwargs = mock_session_class.call_args[1] if mock_session_class.call_args else {}
|
||||
assert call_kwargs.get('aws_access_key_id') == "test-access-key-123"
|
||||
assert call_kwargs.get('aws_secret_access_key') == "test-secret-key-456"
|
||||
|
||||
|
||||
def test_bedrock_token_usage_tracking():
|
||||
"""
|
||||
Test that token usage is properly tracked for Bedrock responses
|
||||
"""
|
||||
llm = LLM(model="bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0")
|
||||
|
||||
# Mock the Bedrock response with usage information
|
||||
with patch.object(llm.client, 'converse') as mock_converse:
|
||||
mock_response = {
|
||||
'output': {
|
||||
'message': {
|
||||
'role': 'assistant',
|
||||
'content': [
|
||||
{'text': 'test response'}
|
||||
]
|
||||
}
|
||||
},
|
||||
'usage': {
|
||||
'inputTokens': 50,
|
||||
'outputTokens': 25,
|
||||
'totalTokens': 75
|
||||
}
|
||||
}
|
||||
mock_converse.return_value = mock_response
|
||||
|
||||
result = llm.call("Hello")
|
||||
|
||||
# Verify the response
|
||||
assert result == "test response"
|
||||
|
||||
# Verify token usage was tracked
|
||||
assert llm._token_usage['prompt_tokens'] == 50
|
||||
assert llm._token_usage['completion_tokens'] == 25
|
||||
assert llm._token_usage['total_tokens'] == 75
|
||||
|
||||
|
||||
def test_bedrock_tool_use_conversation_flow():
|
||||
"""
|
||||
Test that the Bedrock completion properly handles tool use conversation flow
|
||||
"""
|
||||
from unittest.mock import Mock
|
||||
|
||||
# Create BedrockCompletion instance
|
||||
llm = LLM(model="bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0")
|
||||
|
||||
# Mock tool function
|
||||
def mock_weather_tool(location: str) -> str:
|
||||
return f"The weather in {location} is sunny and 75°F"
|
||||
|
||||
available_functions = {"get_weather": mock_weather_tool}
|
||||
|
||||
# Mock the Bedrock client responses
|
||||
with patch.object(llm.client, 'converse') as mock_converse:
|
||||
# First response: tool use request
|
||||
tool_use_response = {
|
||||
'output': {
|
||||
'message': {
|
||||
'role': 'assistant',
|
||||
'content': [
|
||||
{
|
||||
'toolUse': {
|
||||
'toolUseId': 'tool-123',
|
||||
'name': 'get_weather',
|
||||
'input': {'location': 'San Francisco'}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
'usage': {
|
||||
'inputTokens': 100,
|
||||
'outputTokens': 50,
|
||||
'totalTokens': 150
|
||||
}
|
||||
}
|
||||
|
||||
# Second response: final answer after tool execution
|
||||
final_response = {
|
||||
'output': {
|
||||
'message': {
|
||||
'role': 'assistant',
|
||||
'content': [
|
||||
{'text': 'Based on the weather data, it is sunny and 75°F in San Francisco.'}
|
||||
]
|
||||
}
|
||||
},
|
||||
'usage': {
|
||||
'inputTokens': 120,
|
||||
'outputTokens': 30,
|
||||
'totalTokens': 150
|
||||
}
|
||||
}
|
||||
|
||||
# Configure mock to return different responses on successive calls
|
||||
mock_converse.side_effect = [tool_use_response, final_response]
|
||||
|
||||
# Test the call
|
||||
messages = [{"role": "user", "content": "What's the weather like in San Francisco?"}]
|
||||
result = llm.call(
|
||||
messages=messages,
|
||||
available_functions=available_functions
|
||||
)
|
||||
|
||||
# Verify the final response contains the weather information
|
||||
assert "sunny" in result.lower() or "75" in result
|
||||
|
||||
# Verify that the API was called twice (once for tool use, once for final answer)
|
||||
assert mock_converse.call_count == 2
|
||||
|
||||
|
||||
def test_bedrock_handles_cohere_conversation_requirements():
|
||||
"""
|
||||
Test that Bedrock properly handles Cohere model's requirement for user message at end
|
||||
"""
|
||||
llm = LLM(model="bedrock/cohere.command-r-plus-v1:0")
|
||||
|
||||
# Test message formatting with conversation ending in assistant message
|
||||
test_messages = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi there!"}
|
||||
]
|
||||
|
||||
formatted_messages, system_message = llm._format_messages_for_converse(test_messages)
|
||||
|
||||
# For Cohere models, should add a user message at the end
|
||||
assert formatted_messages[-1]["role"] == "user"
|
||||
assert "continue" in formatted_messages[-1]["content"][0]["text"].lower()
|
||||
|
||||
|
||||
def test_bedrock_client_error_handling():
|
||||
"""
|
||||
Test that Bedrock properly handles various AWS client errors
|
||||
"""
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
llm = LLM(model="bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0")
|
||||
|
||||
# Test ValidationException
|
||||
with patch.object(llm.client, 'converse') as mock_converse:
|
||||
error_response = {
|
||||
'Error': {
|
||||
'Code': 'ValidationException',
|
||||
'Message': 'Invalid request format'
|
||||
}
|
||||
}
|
||||
mock_converse.side_effect = ClientError(error_response, 'converse')
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
llm.call("Hello")
|
||||
assert "validation" in str(exc_info.value).lower()
|
||||
|
||||
# Test ThrottlingException
|
||||
with patch.object(llm.client, 'converse') as mock_converse:
|
||||
error_response = {
|
||||
'Error': {
|
||||
'Code': 'ThrottlingException',
|
||||
'Message': 'Rate limit exceeded'
|
||||
}
|
||||
}
|
||||
mock_converse.side_effect = ClientError(error_response, 'converse')
|
||||
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
llm.call("Hello")
|
||||
assert "throttled" in str(exc_info.value).lower()
|
||||
@@ -11,9 +11,9 @@ from crewai.task import Task
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_google_api_key():
|
||||
"""Automatically mock GOOGLE_API_KEY for all tests in this module."""
|
||||
with patch.dict(os.environ, {"GOOGLE_API_KEY": "test-key"}):
|
||||
def mock_anthropic_api_key():
|
||||
"""Automatically mock ANTHROPIC_API_KEY for all tests in this module."""
|
||||
with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "test-key"}):
|
||||
yield
|
||||
|
||||
|
||||
@@ -120,13 +120,9 @@ def test_gemini_completion_module_is_imported():
|
||||
assert hasattr(completion_mod, 'GeminiCompletion')
|
||||
|
||||
|
||||
def test_native_gemini_raises_error_when_initialization_fails():
|
||||
def test_fallback_to_litellm_when_native_gemini_fails():
|
||||
"""
|
||||
Test that LLM raises ImportError when native Gemini completion fails.
|
||||
|
||||
With the new behavior, when a native provider is in SUPPORTED_NATIVE_PROVIDERS
|
||||
but fails to instantiate, we raise an ImportError instead of silently falling back.
|
||||
This provides clearer error messages to users about missing dependencies.
|
||||
Test that LLM falls back to LiteLLM when native Gemini completion fails
|
||||
"""
|
||||
# Mock the _get_native_provider to return a failing class
|
||||
with patch('crewai.llm.LLM._get_native_provider') as mock_get_provider:
|
||||
@@ -137,13 +133,12 @@ def test_native_gemini_raises_error_when_initialization_fails():
|
||||
|
||||
mock_get_provider.return_value = FailingCompletion
|
||||
|
||||
# This should raise ImportError with clear message
|
||||
with pytest.raises(ImportError) as excinfo:
|
||||
LLM(model="google/gemini-2.0-flash-001")
|
||||
# This should fall back to LiteLLM
|
||||
llm = LLM(model="google/gemini-2.0-flash-001")
|
||||
|
||||
# Verify the error message is helpful
|
||||
assert "Error importing native provider" in str(excinfo.value)
|
||||
assert "Native Google Gen AI SDK failed" in str(excinfo.value)
|
||||
# Check that it's using LiteLLM
|
||||
assert hasattr(llm, 'is_litellm')
|
||||
assert llm.is_litellm == True
|
||||
|
||||
|
||||
def test_gemini_completion_initialization_parameters():
|
||||
@@ -386,19 +381,18 @@ def test_gemini_raises_error_when_model_not_supported():
|
||||
mock_client = MagicMock()
|
||||
mock_genai.Client.return_value = mock_client
|
||||
|
||||
# Mock the error that Google would raise for unsupported models
|
||||
from google.genai.errors import ClientError # type: ignore
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.body_segments = [{
|
||||
'error': {
|
||||
'code': 404,
|
||||
'message': 'models/model-doesnt-exist is not found for API version v1beta, or is not supported for generateContent.',
|
||||
'status': 'NOT_FOUND'
|
||||
mock_client.models.generate_content.side_effect = ClientError(
|
||||
code=404,
|
||||
response_json={
|
||||
'error': {
|
||||
'code': 404,
|
||||
'message': 'models/model-doesnt-exist is not found for API version v1beta, or is not supported for generateContent.',
|
||||
'status': 'NOT_FOUND'
|
||||
}
|
||||
}
|
||||
}]
|
||||
mock_response.status_code = 404
|
||||
|
||||
mock_client.models.generate_content.side_effect = ClientError(404, mock_response)
|
||||
)
|
||||
|
||||
llm = LLM(model="google/model-doesnt-exist")
|
||||
|
||||
|
||||
@@ -81,10 +81,9 @@ def test_openai_completion_module_is_imported():
|
||||
assert hasattr(completion_mod, 'OpenAICompletion')
|
||||
|
||||
|
||||
def test_native_openai_raises_error_when_initialization_fails():
|
||||
def test_fallback_to_litellm_when_native_fails():
|
||||
"""
|
||||
Test that LLM raises ImportError when native OpenAI completion fails to initialize.
|
||||
This ensures we don't silently fall back when there's a configuration issue.
|
||||
Test that LLM falls back to LiteLLM when native OpenAI completion fails
|
||||
"""
|
||||
# Mock the _get_native_provider to return a failing class
|
||||
with patch('crewai.llm.LLM._get_native_provider') as mock_get_provider:
|
||||
@@ -95,12 +94,12 @@ def test_native_openai_raises_error_when_initialization_fails():
|
||||
|
||||
mock_get_provider.return_value = FailingCompletion
|
||||
|
||||
# This should raise ImportError, not fall back to LiteLLM
|
||||
with pytest.raises(ImportError) as excinfo:
|
||||
LLM(model="openai/gpt-4o")
|
||||
# This should fall back to LiteLLM
|
||||
llm = LLM(model="openai/gpt-4o")
|
||||
|
||||
assert "Error importing native provider" in str(excinfo.value)
|
||||
assert "Native SDK failed" in str(excinfo.value)
|
||||
# Check that it's using LiteLLM
|
||||
assert hasattr(llm, 'is_litellm')
|
||||
assert llm.is_litellm == True
|
||||
|
||||
|
||||
def test_openai_completion_initialization_parameters():
|
||||
@@ -408,77 +407,3 @@ def test_extra_arguments_are_passed_to_openai_completion():
|
||||
assert call_kwargs['max_tokens'] == 1000
|
||||
assert call_kwargs['top_p'] == 0.5
|
||||
assert call_kwargs['model'] == 'gpt-4o'
|
||||
|
||||
|
||||
|
||||
def test_openai_get_client_params_with_api_base():
|
||||
"""
|
||||
Test that _get_client_params correctly converts api_base to base_url
|
||||
"""
|
||||
llm = OpenAICompletion(
|
||||
model="gpt-4o",
|
||||
api_base="https://custom.openai.com/v1",
|
||||
)
|
||||
client_params = llm._get_client_params()
|
||||
assert client_params["base_url"] == "https://custom.openai.com/v1"
|
||||
|
||||
def test_openai_get_client_params_with_base_url_priority():
|
||||
"""
|
||||
Test that base_url takes priority over api_base in _get_client_params
|
||||
"""
|
||||
llm = OpenAICompletion(
|
||||
model="gpt-4o",
|
||||
base_url="https://priority.openai.com/v1",
|
||||
api_base="https://fallback.openai.com/v1",
|
||||
)
|
||||
client_params = llm._get_client_params()
|
||||
assert client_params["base_url"] == "https://priority.openai.com/v1"
|
||||
|
||||
def test_openai_get_client_params_with_env_var():
|
||||
"""
|
||||
Test that _get_client_params uses OPENAI_BASE_URL environment variable as fallback
|
||||
"""
|
||||
with patch.dict(os.environ, {
|
||||
"OPENAI_BASE_URL": "https://env.openai.com/v1",
|
||||
}):
|
||||
llm = OpenAICompletion(model="gpt-4o")
|
||||
client_params = llm._get_client_params()
|
||||
assert client_params["base_url"] == "https://env.openai.com/v1"
|
||||
|
||||
def test_openai_get_client_params_priority_order():
|
||||
"""
|
||||
Test the priority order: base_url > api_base > OPENAI_BASE_URL env var
|
||||
"""
|
||||
with patch.dict(os.environ, {
|
||||
"OPENAI_BASE_URL": "https://env.openai.com/v1",
|
||||
}):
|
||||
# Test base_url beats api_base and env var
|
||||
llm1 = OpenAICompletion(
|
||||
model="gpt-4o",
|
||||
base_url="https://base-url.openai.com/v1",
|
||||
api_base="https://api-base.openai.com/v1",
|
||||
)
|
||||
params1 = llm1._get_client_params()
|
||||
assert params1["base_url"] == "https://base-url.openai.com/v1"
|
||||
|
||||
# Test api_base beats env var when base_url is None
|
||||
llm2 = OpenAICompletion(
|
||||
model="gpt-4o",
|
||||
api_base="https://api-base.openai.com/v1",
|
||||
)
|
||||
params2 = llm2._get_client_params()
|
||||
assert params2["base_url"] == "https://api-base.openai.com/v1"
|
||||
|
||||
# Test env var is used when both base_url and api_base are None
|
||||
llm3 = OpenAICompletion(model="gpt-4o")
|
||||
params3 = llm3._get_client_params()
|
||||
assert params3["base_url"] == "https://env.openai.com/v1"
|
||||
|
||||
def test_openai_get_client_params_no_base_url():
|
||||
"""
|
||||
Test that _get_client_params works correctly when no base_url is specified
|
||||
"""
|
||||
llm = OpenAICompletion(model="gpt-4o")
|
||||
client_params = llm._get_client_params()
|
||||
# When no base_url is provided, it should not be in the params (filtered out as None)
|
||||
assert "base_url" not in client_params or client_params.get("base_url") is None
|
||||
|
||||
@@ -961,75 +961,3 @@ def test_flow_name():
|
||||
|
||||
flow = MyFlow()
|
||||
assert flow.name == "MyFlow"
|
||||
|
||||
|
||||
def test_nested_and_or_conditions():
|
||||
"""Test nested conditions like or_(and_(A, B), and_(C, D)).
|
||||
|
||||
Reproduces bug from issue #3719 where nested conditions are flattened,
|
||||
causing premature execution.
|
||||
"""
|
||||
execution_order = []
|
||||
|
||||
class NestedConditionFlow(Flow):
|
||||
@start()
|
||||
def method_1(self):
|
||||
execution_order.append("method_1")
|
||||
|
||||
@listen(method_1)
|
||||
def method_2(self):
|
||||
execution_order.append("method_2")
|
||||
|
||||
@router(method_2)
|
||||
def method_3(self):
|
||||
execution_order.append("method_3")
|
||||
# Choose b_condition path
|
||||
return "b_condition"
|
||||
|
||||
@listen("b_condition")
|
||||
def method_5(self):
|
||||
execution_order.append("method_5")
|
||||
|
||||
@listen(method_5)
|
||||
async def method_4(self):
|
||||
execution_order.append("method_4")
|
||||
|
||||
@listen(or_("a_condition", "b_condition"))
|
||||
async def method_6(self):
|
||||
execution_order.append("method_6")
|
||||
|
||||
@listen(
|
||||
or_(
|
||||
and_("a_condition", method_6),
|
||||
and_(method_6, method_4),
|
||||
)
|
||||
)
|
||||
def method_7(self):
|
||||
execution_order.append("method_7")
|
||||
|
||||
@listen(method_7)
|
||||
async def method_8(self):
|
||||
execution_order.append("method_8")
|
||||
|
||||
flow = NestedConditionFlow()
|
||||
flow.kickoff()
|
||||
|
||||
# Verify execution happened
|
||||
assert "method_1" in execution_order
|
||||
assert "method_2" in execution_order
|
||||
assert "method_3" in execution_order
|
||||
assert "method_5" in execution_order
|
||||
assert "method_4" in execution_order
|
||||
assert "method_6" in execution_order
|
||||
assert "method_7" in execution_order
|
||||
assert "method_8" in execution_order
|
||||
|
||||
# Critical assertion: method_7 should only execute AFTER both method_6 AND method_4
|
||||
# Since b_condition was returned, method_6 triggers on b_condition
|
||||
# method_7 requires: (a_condition AND method_6) OR (method_6 AND method_4)
|
||||
# The second condition (method_6 AND method_4) should be the one that triggers
|
||||
assert execution_order.index("method_7") > execution_order.index("method_6")
|
||||
assert execution_order.index("method_7") > execution_order.index("method_4")
|
||||
|
||||
# method_8 should execute after method_7
|
||||
assert execution_order.index("method_8") > execution_order.index("method_7")
|
||||
|
||||
@@ -215,7 +215,7 @@ def test_get_custom_llm_provider_openrouter():
|
||||
|
||||
|
||||
def test_get_custom_llm_provider_gemini():
|
||||
llm = LLM(model="gemini/gemini-1.5-pro", is_litellm=True)
|
||||
llm = LLM(model="gemini/gemini-1.5-pro")
|
||||
assert llm._get_custom_llm_provider() == "gemini"
|
||||
|
||||
|
||||
@@ -243,7 +243,7 @@ def test_validate_call_params_not_supported():
|
||||
|
||||
# Patch supports_response_schema to simulate an unsupported model.
|
||||
with patch("crewai.llm.supports_response_schema", return_value=False):
|
||||
llm = LLM(model="gemini/gemini-1.5-pro", response_format=DummyResponse, is_litellm=True)
|
||||
llm = LLM(model="gemini/gemini-1.5-pro", response_format=DummyResponse)
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
llm._validate_call_params()
|
||||
assert "does not support response_format" in str(excinfo.value)
|
||||
@@ -251,7 +251,7 @@ def test_validate_call_params_not_supported():
|
||||
|
||||
def test_validate_call_params_no_response_format():
|
||||
# When no response_format is provided, no validation error should occur.
|
||||
llm = LLM(model="gemini/gemini-1.5-pro", response_format=None, is_litellm=True)
|
||||
llm = LLM(model="gemini/gemini-1.5-pro", response_format=None)
|
||||
llm._validate_call_params()
|
||||
|
||||
|
||||
@@ -267,8 +267,7 @@ def test_validate_call_params_no_response_format():
|
||||
],
|
||||
)
|
||||
def test_gemini_models(model):
|
||||
# Use LiteLLM for VCR compatibility (VCR can intercept HTTP calls but not native SDK calls)
|
||||
llm = LLM(model=model, is_litellm=True)
|
||||
llm = LLM(model=model)
|
||||
result = llm.call("What is the capital of France?")
|
||||
assert isinstance(result, str)
|
||||
assert "Paris" in result
|
||||
@@ -282,8 +281,7 @@ def test_gemini_models(model):
|
||||
],
|
||||
)
|
||||
def test_gemma3(model):
|
||||
# Use LiteLLM for VCR compatibility (VCR can intercept HTTP calls but not native SDK calls)
|
||||
llm = LLM(model=model, is_litellm=True)
|
||||
llm = LLM(model=model)
|
||||
result = llm.call("What is the capital of France?")
|
||||
assert isinstance(result, str)
|
||||
assert "Paris" in result
|
||||
@@ -699,29 +697,3 @@ def test_ollama_does_not_modify_when_last_is_user(ollama_llm):
|
||||
formatted = ollama_llm._format_messages_for_provider(original_messages)
|
||||
|
||||
assert formatted == original_messages
|
||||
|
||||
def test_native_provider_raises_error_when_supported_but_fails():
|
||||
"""Test that when a native provider is in SUPPORTED_NATIVE_PROVIDERS but fails to instantiate, we raise the error."""
|
||||
with patch("crewai.llm.SUPPORTED_NATIVE_PROVIDERS", ["openai"]):
|
||||
with patch("crewai.llm.LLM._get_native_provider") as mock_get_native:
|
||||
# Mock that provider exists but throws an error when instantiated
|
||||
mock_provider = MagicMock()
|
||||
mock_provider.side_effect = ValueError("Native provider initialization failed")
|
||||
mock_get_native.return_value = mock_provider
|
||||
|
||||
with pytest.raises(ImportError) as excinfo:
|
||||
LLM(model="openai/gpt-4", is_litellm=False)
|
||||
|
||||
assert "Error importing native provider" in str(excinfo.value)
|
||||
assert "Native provider initialization failed" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_native_provider_falls_back_to_litellm_when_not_in_supported_list():
|
||||
"""Test that when a provider is not in SUPPORTED_NATIVE_PROVIDERS, we fall back to LiteLLM."""
|
||||
with patch("crewai.llm.SUPPORTED_NATIVE_PROVIDERS", ["openai", "anthropic"]):
|
||||
# Using a provider not in the supported list
|
||||
llm = LLM(model="groq/llama-3.1-70b-versatile", is_litellm=False)
|
||||
|
||||
# Should fall back to LiteLLM
|
||||
assert llm.is_litellm is True
|
||||
assert llm.model == "groq/llama-3.1-70b-versatile"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import threading
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from crewai import Agent, Task
|
||||
from crewai.events.event_bus import crewai_event_bus
|
||||
from crewai.events.event_types import (
|
||||
@@ -14,24 +14,6 @@ from crewai.tasks.llm_guardrail import LLMGuardrail
|
||||
from crewai.tasks.task_output import TaskOutput
|
||||
|
||||
|
||||
def create_smart_task(**kwargs):
|
||||
"""
|
||||
Smart task factory that automatically assigns a mock agent when guardrails are present.
|
||||
This maintains backward compatibility while handling the agent requirement for guardrails.
|
||||
"""
|
||||
guardrails_list = kwargs.get("guardrails")
|
||||
has_guardrails = kwargs.get("guardrail") is not None or (
|
||||
guardrails_list is not None and len(guardrails_list) > 0
|
||||
)
|
||||
|
||||
if has_guardrails and kwargs.get("agent") is None:
|
||||
kwargs["agent"] = Agent(
|
||||
role="test_agent", goal="test_goal", backstory="test_backstory"
|
||||
)
|
||||
|
||||
return Task(**kwargs)
|
||||
|
||||
|
||||
def test_task_without_guardrail():
|
||||
"""Test that tasks work normally without guardrails (backward compatibility)."""
|
||||
agent = Mock()
|
||||
@@ -39,7 +21,7 @@ def test_task_without_guardrail():
|
||||
agent.execute_task.return_value = "test result"
|
||||
agent.crew = None
|
||||
|
||||
task = create_smart_task(description="Test task", expected_output="Output")
|
||||
task = Task(description="Test task", expected_output="Output")
|
||||
|
||||
result = task.execute_sync(agent=agent)
|
||||
assert isinstance(result, TaskOutput)
|
||||
@@ -57,9 +39,7 @@ def test_task_with_successful_guardrail_func():
|
||||
agent.execute_task.return_value = "test result"
|
||||
agent.crew = None
|
||||
|
||||
task = create_smart_task(
|
||||
description="Test task", expected_output="Output", guardrail=guardrail
|
||||
)
|
||||
task = Task(description="Test task", expected_output="Output", guardrail=guardrail)
|
||||
|
||||
result = task.execute_sync(agent=agent)
|
||||
assert isinstance(result, TaskOutput)
|
||||
@@ -77,7 +57,7 @@ def test_task_with_failing_guardrail():
|
||||
agent.execute_task.side_effect = ["bad result", "good result"]
|
||||
agent.crew = None
|
||||
|
||||
task = create_smart_task(
|
||||
task = Task(
|
||||
description="Test task",
|
||||
expected_output="Output",
|
||||
guardrail=guardrail,
|
||||
@@ -104,7 +84,7 @@ def test_task_with_guardrail_retries():
|
||||
agent.execute_task.return_value = "bad result"
|
||||
agent.crew = None
|
||||
|
||||
task = create_smart_task(
|
||||
task = Task(
|
||||
description="Test task",
|
||||
expected_output="Output",
|
||||
guardrail=guardrail,
|
||||
@@ -129,7 +109,7 @@ def test_guardrail_error_in_context():
|
||||
agent.role = "test_agent"
|
||||
agent.crew = None
|
||||
|
||||
task = create_smart_task(
|
||||
task = Task(
|
||||
description="Test task",
|
||||
expected_output="Output",
|
||||
guardrail=guardrail,
|
||||
@@ -196,78 +176,92 @@ def test_task_guardrail_process_output(task_output):
|
||||
def test_guardrail_emits_events(sample_agent):
|
||||
started_guardrail = []
|
||||
completed_guardrail = []
|
||||
all_events_received = threading.Event()
|
||||
expected_started = 3 # 2 from first task, 1 from second
|
||||
expected_completed = 3 # 2 from first task, 1 from second
|
||||
|
||||
task = create_smart_task(
|
||||
task1 = Task(
|
||||
description="Gather information about available books on the First World War",
|
||||
agent=sample_agent,
|
||||
expected_output="A list of available books on the First World War",
|
||||
guardrail="Ensure the authors are from Italy",
|
||||
)
|
||||
|
||||
with crewai_event_bus.scoped_handlers():
|
||||
|
||||
@crewai_event_bus.on(LLMGuardrailStartedEvent)
|
||||
def handle_guardrail_started(source, event):
|
||||
assert source == task
|
||||
started_guardrail.append(
|
||||
{"guardrail": event.guardrail, "retry_count": event.retry_count}
|
||||
)
|
||||
|
||||
@crewai_event_bus.on(LLMGuardrailCompletedEvent)
|
||||
def handle_guardrail_completed(source, event):
|
||||
assert source == task
|
||||
completed_guardrail.append(
|
||||
{
|
||||
"success": event.success,
|
||||
"result": event.result,
|
||||
"error": event.error,
|
||||
"retry_count": event.retry_count,
|
||||
}
|
||||
)
|
||||
|
||||
result = task.execute_sync(agent=sample_agent)
|
||||
|
||||
def custom_guardrail(result: TaskOutput):
|
||||
return (True, "good result from callable function")
|
||||
|
||||
task = create_smart_task(
|
||||
description="Test task",
|
||||
expected_output="Output",
|
||||
guardrail=custom_guardrail,
|
||||
@crewai_event_bus.on(LLMGuardrailStartedEvent)
|
||||
def handle_guardrail_started(source, event):
|
||||
started_guardrail.append(
|
||||
{"guardrail": event.guardrail, "retry_count": event.retry_count}
|
||||
)
|
||||
if (
|
||||
len(started_guardrail) >= expected_started
|
||||
and len(completed_guardrail) >= expected_completed
|
||||
):
|
||||
all_events_received.set()
|
||||
|
||||
task.execute_sync(agent=sample_agent)
|
||||
@crewai_event_bus.on(LLMGuardrailCompletedEvent)
|
||||
def handle_guardrail_completed(source, event):
|
||||
completed_guardrail.append(
|
||||
{
|
||||
"success": event.success,
|
||||
"result": event.result,
|
||||
"error": event.error,
|
||||
"retry_count": event.retry_count,
|
||||
}
|
||||
)
|
||||
if (
|
||||
len(started_guardrail) >= expected_started
|
||||
and len(completed_guardrail) >= expected_completed
|
||||
):
|
||||
all_events_received.set()
|
||||
|
||||
expected_started_events = [
|
||||
{"guardrail": "Ensure the authors are from Italy", "retry_count": 0},
|
||||
{"guardrail": "Ensure the authors are from Italy", "retry_count": 1},
|
||||
{
|
||||
"guardrail": """def custom_guardrail(result: TaskOutput):
|
||||
return (True, "good result from callable function")""",
|
||||
"retry_count": 0,
|
||||
},
|
||||
]
|
||||
result = task1.execute_sync(agent=sample_agent)
|
||||
|
||||
expected_completed_events = [
|
||||
{
|
||||
"success": False,
|
||||
"result": None,
|
||||
"error": "The task result does not comply with the guardrail because none of "
|
||||
"the listed authors are from Italy. All authors mentioned are from "
|
||||
"different countries, including Germany, the UK, the USA, and others, "
|
||||
"which violates the requirement that authors must be Italian.",
|
||||
"retry_count": 0,
|
||||
},
|
||||
{"success": True, "result": result.raw, "error": None, "retry_count": 1},
|
||||
{
|
||||
"success": True,
|
||||
"result": "good result from callable function",
|
||||
"error": None,
|
||||
"retry_count": 0,
|
||||
},
|
||||
]
|
||||
assert started_guardrail == expected_started_events
|
||||
assert completed_guardrail == expected_completed_events
|
||||
def custom_guardrail(result: TaskOutput):
|
||||
return (True, "good result from callable function")
|
||||
|
||||
task2 = Task(
|
||||
description="Test task",
|
||||
expected_output="Output",
|
||||
guardrail=custom_guardrail,
|
||||
)
|
||||
|
||||
task2.execute_sync(agent=sample_agent)
|
||||
|
||||
# Wait for all events to be received
|
||||
assert all_events_received.wait(timeout=10), (
|
||||
"Timeout waiting for all guardrail events"
|
||||
)
|
||||
|
||||
expected_started_events = [
|
||||
{"guardrail": "Ensure the authors are from Italy", "retry_count": 0},
|
||||
{"guardrail": "Ensure the authors are from Italy", "retry_count": 1},
|
||||
{
|
||||
"guardrail": """def custom_guardrail(result: TaskOutput):
|
||||
return (True, "good result from callable function")""",
|
||||
"retry_count": 0,
|
||||
},
|
||||
]
|
||||
|
||||
expected_completed_events = [
|
||||
{
|
||||
"success": False,
|
||||
"result": None,
|
||||
"error": "The task result does not comply with the guardrail because none of "
|
||||
"the listed authors are from Italy. All authors mentioned are from "
|
||||
"different countries, including Germany, the UK, the USA, and others, "
|
||||
"which violates the requirement that authors must be Italian.",
|
||||
"retry_count": 0,
|
||||
},
|
||||
{"success": True, "result": result.raw, "error": None, "retry_count": 1},
|
||||
{
|
||||
"success": True,
|
||||
"result": "good result from callable function",
|
||||
"error": None,
|
||||
"retry_count": 0,
|
||||
},
|
||||
]
|
||||
assert started_guardrail == expected_started_events
|
||||
assert completed_guardrail == expected_completed_events
|
||||
|
||||
|
||||
@pytest.mark.vcr(filter_headers=["authorization"])
|
||||
@@ -282,7 +276,7 @@ def test_guardrail_when_an_error_occurs(sample_agent, task_output):
|
||||
match="Error while validating the task output: Unexpected error",
|
||||
),
|
||||
):
|
||||
task = create_smart_task(
|
||||
task = Task(
|
||||
description="Gather information about available books on the First World War",
|
||||
agent=sample_agent,
|
||||
expected_output="A list of available books on the First World War",
|
||||
@@ -304,7 +298,7 @@ def test_hallucination_guardrail_integration():
|
||||
context="Test reference context for validation", llm=mock_llm, threshold=8.0
|
||||
)
|
||||
|
||||
task = create_smart_task(
|
||||
task = Task(
|
||||
description="Test task with hallucination guardrail",
|
||||
expected_output="Valid output",
|
||||
guardrail=guardrail,
|
||||
@@ -324,401 +318,3 @@ def test_hallucination_guardrail_description_in_events():
|
||||
|
||||
event = LLMGuardrailStartedEvent(guardrail=guardrail, retry_count=0)
|
||||
assert event.guardrail == "HallucinationGuardrail (no-op)"
|
||||
|
||||
|
||||
def test_multiple_guardrails_sequential_processing():
|
||||
"""Test that multiple guardrails are processed sequentially."""
|
||||
|
||||
def first_guardrail(result: TaskOutput) -> tuple[bool, str]:
|
||||
"""First guardrail adds prefix."""
|
||||
return (True, f"[FIRST] {result.raw}")
|
||||
|
||||
def second_guardrail(result: TaskOutput) -> tuple[bool, str]:
|
||||
"""Second guardrail adds suffix."""
|
||||
return (True, f"{result.raw} [SECOND]")
|
||||
|
||||
def third_guardrail(result: TaskOutput) -> tuple[bool, str]:
|
||||
"""Third guardrail converts to uppercase."""
|
||||
return (True, result.raw.upper())
|
||||
|
||||
agent = Mock()
|
||||
agent.role = "sequential_agent"
|
||||
agent.execute_task.return_value = "original text"
|
||||
agent.crew = None
|
||||
|
||||
task = create_smart_task(
|
||||
description="Test sequential guardrails",
|
||||
expected_output="Processed text",
|
||||
guardrails=[first_guardrail, second_guardrail, third_guardrail],
|
||||
)
|
||||
|
||||
result = task.execute_sync(agent=agent)
|
||||
assert result.raw == "[FIRST] ORIGINAL TEXT [SECOND]"
|
||||
|
||||
|
||||
def test_multiple_guardrails_with_validation_failure():
|
||||
"""Test multiple guardrails where one fails validation."""
|
||||
|
||||
def length_guardrail(result: TaskOutput) -> tuple[bool, str]:
|
||||
"""Ensure minimum length."""
|
||||
if len(result.raw) < 10:
|
||||
return (False, "Text too short")
|
||||
return (True, result.raw)
|
||||
|
||||
def format_guardrail(result: TaskOutput) -> tuple[bool, str]:
|
||||
"""Add formatting only if not already formatted."""
|
||||
if not result.raw.startswith("Formatted:"):
|
||||
return (True, f"Formatted: {result.raw}")
|
||||
return (True, result.raw)
|
||||
|
||||
def validation_guardrail(result: TaskOutput) -> tuple[bool, str]:
|
||||
"""Final validation."""
|
||||
if "Formatted:" not in result.raw:
|
||||
return (False, "Missing formatting")
|
||||
return (True, result.raw)
|
||||
|
||||
# Use a callable that tracks calls and returns appropriate values
|
||||
call_count = 0
|
||||
|
||||
def mock_execute_task(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
result = (
|
||||
"short"
|
||||
if call_count == 1
|
||||
else "this is a longer text that meets requirements"
|
||||
)
|
||||
return result
|
||||
|
||||
agent = Mock()
|
||||
agent.role = "validation_agent"
|
||||
agent.execute_task = mock_execute_task
|
||||
agent.crew = None
|
||||
|
||||
task = create_smart_task(
|
||||
description="Test guardrails with validation",
|
||||
expected_output="Valid formatted text",
|
||||
guardrails=[length_guardrail, format_guardrail, validation_guardrail],
|
||||
guardrail_max_retries=2,
|
||||
)
|
||||
|
||||
result = task.execute_sync(agent=agent)
|
||||
# The second call should be processed through all guardrails
|
||||
assert result.raw == "Formatted: this is a longer text that meets requirements"
|
||||
assert task._guardrail_retry_counts.get(0, 0) == 1
|
||||
|
||||
|
||||
def test_multiple_guardrails_with_mixed_string_and_taskoutput():
|
||||
"""Test guardrails that return both strings and TaskOutput objects."""
|
||||
|
||||
def string_guardrail(result: TaskOutput) -> tuple[bool, str]:
|
||||
"""Returns a string."""
|
||||
return (True, f"String: {result.raw}")
|
||||
|
||||
def taskoutput_guardrail(result: TaskOutput) -> tuple[bool, TaskOutput]:
|
||||
"""Returns a TaskOutput object."""
|
||||
new_output = TaskOutput(
|
||||
name=result.name,
|
||||
description=result.description,
|
||||
expected_output=result.expected_output,
|
||||
raw=f"TaskOutput: {result.raw}",
|
||||
agent=result.agent,
|
||||
output_format=result.output_format,
|
||||
)
|
||||
return (True, new_output)
|
||||
|
||||
def final_string_guardrail(result: TaskOutput) -> tuple[bool, str]:
|
||||
"""Final string transformation."""
|
||||
return (True, f"Final: {result.raw}")
|
||||
|
||||
agent = Mock()
|
||||
agent.role = "mixed_agent"
|
||||
agent.execute_task.return_value = "original"
|
||||
agent.crew = None
|
||||
|
||||
task = create_smart_task(
|
||||
description="Test mixed return types",
|
||||
expected_output="Mixed processing",
|
||||
guardrails=[string_guardrail, taskoutput_guardrail, final_string_guardrail],
|
||||
)
|
||||
|
||||
result = task.execute_sync(agent=agent)
|
||||
assert result.raw == "Final: TaskOutput: String: original"
|
||||
|
||||
|
||||
def test_multiple_guardrails_with_retry_on_middle_guardrail():
|
||||
"""Test that retry works correctly when a middle guardrail fails."""
|
||||
|
||||
call_count = {"first": 0, "second": 0, "third": 0}
|
||||
|
||||
def first_guardrail(result: TaskOutput) -> tuple[bool, str]:
|
||||
"""Always succeeds."""
|
||||
call_count["first"] += 1
|
||||
return (True, f"First({call_count['first']}): {result.raw}")
|
||||
|
||||
def second_guardrail(result: TaskOutput) -> tuple[bool, str]:
|
||||
"""Fails on first attempt, succeeds on second."""
|
||||
call_count["second"] += 1
|
||||
if call_count["second"] == 1:
|
||||
return (False, "Second guardrail failed on first attempt")
|
||||
return (True, f"Second({call_count['second']}): {result.raw}")
|
||||
|
||||
def third_guardrail(result: TaskOutput) -> tuple[bool, str]:
|
||||
"""Always succeeds."""
|
||||
call_count["third"] += 1
|
||||
return (True, f"Third({call_count['third']}): {result.raw}")
|
||||
|
||||
agent = Mock()
|
||||
agent.role = "retry_agent"
|
||||
agent.execute_task.return_value = "base"
|
||||
agent.crew = None
|
||||
|
||||
task = create_smart_task(
|
||||
description="Test retry in middle guardrail",
|
||||
expected_output="Retry handling",
|
||||
guardrails=[first_guardrail, second_guardrail, third_guardrail],
|
||||
guardrail_max_retries=2,
|
||||
)
|
||||
|
||||
result = task.execute_sync(agent=agent)
|
||||
assert task._guardrail_retry_counts.get(1, 0) == 1
|
||||
assert call_count["first"] == 1
|
||||
assert call_count["second"] == 2
|
||||
assert call_count["third"] == 1
|
||||
assert "Second(2)" in result.raw
|
||||
|
||||
|
||||
def test_multiple_guardrails_with_max_retries_exceeded():
|
||||
"""Test that exception is raised when max retries exceeded with multiple guardrails."""
|
||||
|
||||
def passing_guardrail(result: TaskOutput) -> tuple[bool, str]:
|
||||
"""Always passes."""
|
||||
return (True, f"Passed: {result.raw}")
|
||||
|
||||
def failing_guardrail(result: TaskOutput) -> tuple[bool, str]:
|
||||
"""Always fails."""
|
||||
return (False, "This guardrail always fails")
|
||||
|
||||
agent = Mock()
|
||||
agent.role = "failing_agent"
|
||||
agent.execute_task.return_value = "test"
|
||||
agent.crew = None
|
||||
|
||||
task = create_smart_task(
|
||||
description="Test max retries with multiple guardrails",
|
||||
expected_output="Will fail",
|
||||
guardrails=[passing_guardrail, failing_guardrail],
|
||||
guardrail_max_retries=1,
|
||||
)
|
||||
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
task.execute_sync(agent=agent)
|
||||
|
||||
assert "Task failed guardrail 1 validation after 1 retries" in str(exc_info.value)
|
||||
assert "This guardrail always fails" in str(exc_info.value)
|
||||
assert task._guardrail_retry_counts.get(1, 0) == 1
|
||||
|
||||
|
||||
def test_multiple_guardrails_empty_list():
|
||||
"""Test that empty guardrails list works correctly."""
|
||||
|
||||
agent = Mock()
|
||||
agent.role = "empty_agent"
|
||||
agent.execute_task.return_value = "no guardrails"
|
||||
agent.crew = None
|
||||
|
||||
task = create_smart_task(
|
||||
description="Test empty guardrails list",
|
||||
expected_output="No processing",
|
||||
guardrails=[],
|
||||
)
|
||||
|
||||
result = task.execute_sync(agent=agent)
|
||||
assert result.raw == "no guardrails"
|
||||
|
||||
|
||||
def test_multiple_guardrails_with_llm_guardrails():
|
||||
"""Test mixing callable and LLM guardrails."""
|
||||
|
||||
def callable_guardrail(result: TaskOutput) -> tuple[bool, str]:
|
||||
"""Callable guardrail."""
|
||||
return (True, f"Callable: {result.raw}")
|
||||
|
||||
# Create a proper mock agent without config issues
|
||||
from crewai import Agent
|
||||
|
||||
agent = Agent(
|
||||
role="mixed_guardrail_agent", goal="Test goal", backstory="Test backstory"
|
||||
)
|
||||
|
||||
task = create_smart_task(
|
||||
description="Test mixed guardrail types",
|
||||
expected_output="Mixed processing",
|
||||
guardrails=[callable_guardrail, "Ensure the output is professional"],
|
||||
agent=agent,
|
||||
)
|
||||
|
||||
# The LLM guardrail will be converted to LLMGuardrail internally
|
||||
assert len(task._guardrails) == 2
|
||||
assert callable(task._guardrails[0])
|
||||
assert callable(task._guardrails[1]) # LLMGuardrail is callable
|
||||
|
||||
|
||||
def test_multiple_guardrails_processing_order():
|
||||
"""Test that guardrails are processed in the correct order."""
|
||||
|
||||
processing_order = []
|
||||
|
||||
def first_guardrail(result: TaskOutput) -> tuple[bool, str]:
|
||||
processing_order.append("first")
|
||||
return (True, f"1-{result.raw}")
|
||||
|
||||
def second_guardrail(result: TaskOutput) -> tuple[bool, str]:
|
||||
processing_order.append("second")
|
||||
return (True, f"2-{result.raw}")
|
||||
|
||||
def third_guardrail(result: TaskOutput) -> tuple[bool, str]:
|
||||
processing_order.append("third")
|
||||
return (True, f"3-{result.raw}")
|
||||
|
||||
agent = Mock()
|
||||
agent.role = "order_agent"
|
||||
agent.execute_task.return_value = "base"
|
||||
agent.crew = None
|
||||
|
||||
task = create_smart_task(
|
||||
description="Test processing order",
|
||||
expected_output="Ordered processing",
|
||||
guardrails=[first_guardrail, second_guardrail, third_guardrail],
|
||||
)
|
||||
|
||||
result = task.execute_sync(agent=agent)
|
||||
assert processing_order == ["first", "second", "third"]
|
||||
assert result.raw == "3-2-1-base"
|
||||
|
||||
|
||||
def test_multiple_guardrails_with_pydantic_output():
|
||||
"""Test multiple guardrails with Pydantic output model."""
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
class TestModel(BaseModel):
|
||||
content: str = Field(description="The content")
|
||||
processed: bool = Field(description="Whether it was processed")
|
||||
|
||||
def json_guardrail(result: TaskOutput) -> tuple[bool, str]:
|
||||
"""Convert to JSON format."""
|
||||
import json
|
||||
|
||||
data = {"content": result.raw, "processed": True}
|
||||
return (True, json.dumps(data))
|
||||
|
||||
def validation_guardrail(result: TaskOutput) -> tuple[bool, str]:
|
||||
"""Validate JSON structure."""
|
||||
import json
|
||||
|
||||
try:
|
||||
data = json.loads(result.raw)
|
||||
if "content" not in data or "processed" not in data:
|
||||
return (False, "Missing required fields")
|
||||
return (True, result.raw)
|
||||
except json.JSONDecodeError:
|
||||
return (False, "Invalid JSON format")
|
||||
|
||||
agent = Mock()
|
||||
agent.role = "pydantic_agent"
|
||||
agent.execute_task.return_value = "test content"
|
||||
agent.crew = None
|
||||
|
||||
task = create_smart_task(
|
||||
description="Test guardrails with Pydantic",
|
||||
expected_output="Structured output",
|
||||
guardrails=[json_guardrail, validation_guardrail],
|
||||
output_pydantic=TestModel,
|
||||
)
|
||||
|
||||
result = task.execute_sync(agent=agent)
|
||||
|
||||
# Verify the result is valid JSON and can be parsed
|
||||
import json
|
||||
|
||||
parsed = json.loads(result.raw)
|
||||
assert parsed["content"] == "test content"
|
||||
assert parsed["processed"] is True
|
||||
|
||||
|
||||
def test_guardrails_vs_single_guardrail_mutual_exclusion():
|
||||
"""Test that guardrails list nullifies single guardrail."""
|
||||
|
||||
def single_guardrail(result: TaskOutput) -> tuple[bool, str]:
|
||||
"""Single guardrail - should be ignored."""
|
||||
return (True, f"Single: {result.raw}")
|
||||
|
||||
def list_guardrail(result: TaskOutput) -> tuple[bool, str]:
|
||||
"""List guardrail - should be used."""
|
||||
return (True, f"List: {result.raw}")
|
||||
|
||||
agent = Mock()
|
||||
agent.role = "exclusion_agent"
|
||||
agent.execute_task.return_value = "test"
|
||||
agent.crew = None
|
||||
|
||||
task = create_smart_task(
|
||||
description="Test mutual exclusion",
|
||||
expected_output="Exclusion test",
|
||||
guardrail=single_guardrail, # This should be ignored
|
||||
guardrails=[list_guardrail], # This should be used
|
||||
)
|
||||
|
||||
result = task.execute_sync(agent=agent)
|
||||
# Should only use the guardrails list, not the single guardrail
|
||||
assert result.raw == "List: test"
|
||||
assert task._guardrail is None # Single guardrail should be nullified
|
||||
|
||||
|
||||
def test_per_guardrail_independent_retry_tracking():
|
||||
"""Test that each guardrail has independent retry tracking."""
|
||||
|
||||
call_counts = {"g1": 0, "g2": 0, "g3": 0}
|
||||
|
||||
def guardrail_1(result: TaskOutput) -> tuple[bool, str]:
|
||||
"""Fails twice, then succeeds."""
|
||||
call_counts["g1"] += 1
|
||||
if call_counts["g1"] <= 2:
|
||||
return (False, "Guardrail 1 not ready yet")
|
||||
return (True, f"G1({call_counts['g1']}): {result.raw}")
|
||||
|
||||
def guardrail_2(result: TaskOutput) -> tuple[bool, str]:
|
||||
"""Fails once, then succeeds."""
|
||||
call_counts["g2"] += 1
|
||||
if call_counts["g2"] == 1:
|
||||
return (False, "Guardrail 2 not ready yet")
|
||||
return (True, f"G2({call_counts['g2']}): {result.raw}")
|
||||
|
||||
def guardrail_3(result: TaskOutput) -> tuple[bool, str]:
|
||||
"""Always succeeds."""
|
||||
call_counts["g3"] += 1
|
||||
return (True, f"G3({call_counts['g3']}): {result.raw}")
|
||||
|
||||
agent = Mock()
|
||||
agent.role = "independent_retry_agent"
|
||||
agent.execute_task.return_value = "base"
|
||||
agent.crew = None
|
||||
|
||||
task = create_smart_task(
|
||||
description="Test independent retry tracking",
|
||||
expected_output="Independent retries",
|
||||
guardrails=[guardrail_1, guardrail_2, guardrail_3],
|
||||
guardrail_max_retries=3,
|
||||
)
|
||||
|
||||
result = task.execute_sync(agent=agent)
|
||||
|
||||
assert task._guardrail_retry_counts.get(0, 0) == 2
|
||||
assert task._guardrail_retry_counts.get(1, 0) == 1
|
||||
assert task._guardrail_retry_counts.get(2, 0) == 0
|
||||
|
||||
assert call_counts["g1"] == 3
|
||||
assert call_counts["g2"] == 2
|
||||
assert call_counts["g3"] == 1
|
||||
|
||||
assert "G3(1)" in result.raw
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""CrewAI development tools."""
|
||||
|
||||
__version__ = "1.0.0b3"
|
||||
__version__ = "1.0.0b2"
|
||||
|
||||
@@ -25,13 +25,11 @@ dev = [
|
||||
"types-pyyaml==6.0.*",
|
||||
"types-regex==2024.11.6.*",
|
||||
"types-appdirs==1.4.*",
|
||||
"boto3-stubs[bedrock-runtime]>=1.40.54",
|
||||
]
|
||||
|
||||
|
||||
[tool.ruff]
|
||||
src = ["lib/*"]
|
||||
extend-exclude = [
|
||||
exclude = [
|
||||
"lib/crewai/src/crewai/cli/templates",
|
||||
"lib/crewai/tests/",
|
||||
"lib/crewai-tools/tests/",
|
||||
|
||||
237
uv.lock
generated
237
uv.lock
generated
@@ -38,7 +38,6 @@ members = [
|
||||
[manifest.dependency-groups]
|
||||
dev = [
|
||||
{ name = "bandit", specifier = ">=1.8.6" },
|
||||
{ name = "boto3-stubs", extras = ["bedrock-runtime"], specifier = ">=1.40.54" },
|
||||
{ name = "mypy", specifier = ">=1.18.2" },
|
||||
{ name = "pre-commit", specifier = ">=4.3.0" },
|
||||
{ name = "pytest", specifier = ">=8.4.2" },
|
||||
@@ -344,33 +343,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/aa/5082412d1ee302e9e7d80b6949bc4d2a8fa1149aaab610c5fc24709605d6/authlib-1.6.5-py2.py3-none-any.whl", hash = "sha256:3e0e0507807f842b02175507bdee8957a1d5707fd4afb17c32fb43fee90b6e3a", size = 243608, upload-time = "2025-10-02T13:36:07.637Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "azure-ai-inference"
|
||||
version = "1.0.0b9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "azure-core" },
|
||||
{ name = "isodate" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4e/6a/ed85592e5c64e08c291992f58b1a94dab6869f28fb0f40fd753dced73ba6/azure_ai_inference-1.0.0b9.tar.gz", hash = "sha256:1feb496bd84b01ee2691befc04358fa25d7c344d8288e99364438859ad7cd5a4", size = 182408, upload-time = "2025-02-15T00:37:28.464Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/0f/27520da74769db6e58327d96c98e7b9a07ce686dff582c9a5ec60b03f9dd/azure_ai_inference-1.0.0b9-py3-none-any.whl", hash = "sha256:49823732e674092dad83bb8b0d1b65aa73111fab924d61349eb2a8cdc0493990", size = 124885, upload-time = "2025-02-15T00:37:29.964Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "azure-core"
|
||||
version = "1.36.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "requests" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0a/c4/d4ff3bc3ddf155156460bff340bbe9533f99fac54ddea165f35a8619f162/azure_core-1.36.0.tar.gz", hash = "sha256:22e5605e6d0bf1d229726af56d9e92bc37b6e726b141a18be0b4d424131741b7", size = 351139, upload-time = "2025-10-15T00:33:49.083Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/3c/b90d5afc2e47c4a45f4bba00f9c3193b0417fad5ad3bb07869f9d12832aa/azure_core-1.36.0-py3-none-any.whl", hash = "sha256:fee9923a3a753e94a259563429f3644aaf05c486d45b1215d098115102d91d3b", size = 213302, upload-time = "2025-10-15T00:33:51.058Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "backoff"
|
||||
version = "2.2.1"
|
||||
@@ -507,25 +479,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/db/7d3c27f530c2b354d546ad7fb94505be8b78a5ecabe34c6a1f9a9d6be03e/boto3-1.40.45-py3-none-any.whl", hash = "sha256:5b145752d20f29908e3cb8c823bee31c77e6bcf18787e570f36bbc545cc779ed", size = 139345, upload-time = "2025-10-03T19:32:11.145Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "boto3-stubs"
|
||||
version = "1.40.54"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "botocore-stubs" },
|
||||
{ name = "types-s3transfer" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.12'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e2/70/245477b7f07c9e1533c47fa69e611b172814423a6fd4637004f0d2a13b73/boto3_stubs-1.40.54.tar.gz", hash = "sha256:e21a9eda979a451935eb3196de3efbe15b9470e6bf9027406d1f6d0ac08b339e", size = 100919, upload-time = "2025-10-16T19:49:17.079Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/52/ee9dadd1cc8911e16f18ca9fa036a10328e0a0d3fddd54fadcc1ca0f9143/boto3_stubs-1.40.54-py3-none-any.whl", hash = "sha256:548a4786785ba7b43ef4ef1a2a764bebbb0301525f3201091fcf412e4c8ce323", size = 69712, upload-time = "2025-10-16T19:49:12.847Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
bedrock-runtime = [
|
||||
{ name = "mypy-boto3-bedrock-runtime" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "botocore"
|
||||
version = "1.40.45"
|
||||
@@ -541,18 +494,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/af/06/df47e2ecb74bd184c9d056666afd3db011a649eaca663337835a6dd5aee6/botocore-1.40.45-py3-none-any.whl", hash = "sha256:9abf473d8372ade8442c0d4634a9decb89c854d7862ffd5500574eb63ab8f240", size = 14063670, upload-time = "2025-10-03T19:31:58.999Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "botocore-stubs"
|
||||
version = "1.40.54"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "types-awscrt" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ea/c0/3e78314f9baa850aae648fb6b2506748046e1c3e374d6bb3514478e34590/botocore_stubs-1.40.54.tar.gz", hash = "sha256:fb38a794ab2b896f9cc237ec725546746accaffd34f382475a8d1b98ca1078e1", size = 42225, upload-time = "2025-10-16T20:26:56.711Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/9f/ab316f57a7e32d4a5b790070ffa5986991098044897b08f1b65951bced2a/botocore_stubs-1.40.54-py3-none-any.whl", hash = "sha256:997e6f1c03e079c244caedf315f7a515a07480af9f93f53535e506f17cdbe880", size = 66542, upload-time = "2025-10-16T20:26:54.109Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "browserbase"
|
||||
version = "1.4.0"
|
||||
@@ -1066,16 +1007,10 @@ dependencies = [
|
||||
aisuite = [
|
||||
{ name = "aisuite" },
|
||||
]
|
||||
anthropic = [
|
||||
{ name = "anthropic" },
|
||||
]
|
||||
aws = [
|
||||
{ name = "boto3" },
|
||||
]
|
||||
azure-ai-inference = [
|
||||
{ name = "azure-ai-inference" },
|
||||
]
|
||||
bedrock = [
|
||||
boto3 = [
|
||||
{ name = "boto3" },
|
||||
]
|
||||
docling = [
|
||||
@@ -1084,9 +1019,6 @@ docling = [
|
||||
embeddings = [
|
||||
{ name = "tiktoken" },
|
||||
]
|
||||
google-genai = [
|
||||
{ name = "google-genai" },
|
||||
]
|
||||
litellm = [
|
||||
{ name = "litellm" },
|
||||
]
|
||||
@@ -1118,16 +1050,13 @@ watson = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aisuite", marker = "extra == 'aisuite'", specifier = ">=0.1.10" },
|
||||
{ name = "anthropic", marker = "extra == 'anthropic'", specifier = ">=0.69.0" },
|
||||
{ name = "appdirs", specifier = ">=1.4.4" },
|
||||
{ name = "azure-ai-inference", marker = "extra == 'azure-ai-inference'", specifier = ">=1.0.0b9" },
|
||||
{ name = "boto3", marker = "extra == 'aws'", specifier = ">=1.40.38" },
|
||||
{ name = "boto3", marker = "extra == 'bedrock'", specifier = ">=1.40.45" },
|
||||
{ name = "boto3", marker = "extra == 'boto3'", specifier = ">=1.40.45" },
|
||||
{ name = "chromadb", specifier = "~=1.1.0" },
|
||||
{ name = "click", specifier = ">=8.1.7" },
|
||||
{ name = "crewai-tools", marker = "extra == 'tools'", editable = "lib/crewai-tools" },
|
||||
{ name = "docling", marker = "extra == 'docling'", specifier = ">=2.12.0" },
|
||||
{ name = "google-genai", marker = "extra == 'google-genai'", specifier = ">=1.2.0" },
|
||||
{ name = "ibm-watsonx-ai", marker = "extra == 'watson'", specifier = ">=1.3.39" },
|
||||
{ name = "instructor", specifier = ">=1.3.3" },
|
||||
{ name = "json-repair", specifier = "==0.25.2" },
|
||||
@@ -1159,7 +1088,7 @@ requires-dist = [
|
||||
{ name = "uv", specifier = ">=0.4.25" },
|
||||
{ name = "voyageai", marker = "extra == 'voyageai'", specifier = ">=0.3.5" },
|
||||
]
|
||||
provides-extras = ["aisuite", "anthropic", "aws", "azure-ai-inference", "bedrock", "docling", "embeddings", "google-genai", "litellm", "mem0", "openpyxl", "pandas", "pdfplumber", "qdrant", "tools", "voyageai", "watson"]
|
||||
provides-extras = ["aisuite", "aws", "boto3", "docling", "embeddings", "litellm", "mem0", "openpyxl", "pandas", "pdfplumber", "qdrant", "tools", "voyageai", "watson"]
|
||||
|
||||
[[package]]
|
||||
name = "crewai-devtools"
|
||||
@@ -2186,21 +2115,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/39/aa/db9febba7b5bd9c9d772e935a5c495fb2b4ee05299e46c6c4b1e7c0b66b2/google_cloud_vision-3.10.2-py3-none-any.whl", hash = "sha256:42a17fbc2219b0a88e325e2c1df6664a8dafcbae66363fb37ebcb511b018fc87", size = 527877, upload-time = "2025-06-12T01:09:57.275Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-genai"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "google-auth" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "requests" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/ed/985f2d2e2b5fbd912ab0fdb11d6dc48c22553a6c4edffabb8146d53b974a/google_genai-1.2.0-py3-none-any.whl", hash = "sha256:609d61bee73f1a6ae5b47e9c7dd4b469d50318f050c5ceacf835b0f80f79d2d9", size = 130744, upload-time = "2025-02-12T16:40:03.601Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "googleapis-common-protos"
|
||||
version = "1.70.0"
|
||||
@@ -2947,15 +2861,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "isodate"
|
||||
version = "0.7.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705, upload-time = "2024-10-08T23:04:11.5Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jedi"
|
||||
version = "0.19.2"
|
||||
@@ -4131,18 +4036,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367, upload-time = "2025-09-19T00:10:15.489Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mypy-boto3-bedrock-runtime"
|
||||
version = "1.40.41"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.12'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/38/79989f7bce998776ed1a01c17f3f58e7bc6f5fc2bcbdff929701526fa2f1/mypy_boto3_bedrock_runtime-1.40.41.tar.gz", hash = "sha256:ee9bda6d6d478c8d0995e84e884bdf1798e150d437974ae27c175774a58ffaa5", size = 28333, upload-time = "2025-09-29T19:26:04.804Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/6c/d3431dadf473bb76aa590b1ed8cc91726a48b029b542eff9d3024f2d70b9/mypy_boto3_bedrock_runtime-1.40.41-py3-none-any.whl", hash = "sha256:d65dff200986ff06c6b3579ddcea102555f2067c8987fca379bf4f9ed8ba3121", size = 34181, upload-time = "2025-09-29T19:26:01.898Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mypy-extensions"
|
||||
version = "1.1.0"
|
||||
@@ -8269,15 +8162,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/07/41f5b9b11f11855eb67760ed680330e0ce9136a44b51c24dd52edb1c4eb1/types_appdirs-1.4.3.5-py3-none-any.whl", hash = "sha256:337c750e423c40911d389359b4edabe5bbc2cdd5cd0bd0518b71d2839646273b", size = 2667, upload-time = "2023-03-14T15:21:32.431Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "types-awscrt"
|
||||
version = "0.28.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/60/19/a3a6377c9e2e389c1421c033a1830c29cac08f2e1e05a082ea84eb22c75f/types_awscrt-0.28.1.tar.gz", hash = "sha256:66d77ec283e1dc907526a44511a12624118723a396c36d3f3dd9855cb614ce14", size = 17410, upload-time = "2025-10-11T21:55:07.443Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/c7/0266b797d19b82aebe0e177efe35de7aabdc192bc1605ce3309331f0a505/types_awscrt-0.28.1-py3-none-any.whl", hash = "sha256:d88f43ef779f90b841ba99badb72fe153077225a4e426ae79e943184827b4443", size = 41851, upload-time = "2025-10-11T21:55:06.235Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "types-pyyaml"
|
||||
version = "6.0.12.20250915"
|
||||
@@ -8348,15 +8232,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/ea/91b718b8c0b88e4f61cdd61357cc4a1f8767b32be691fb388299003a3ae3/types_requests-2.31.0.20240406-py3-none-any.whl", hash = "sha256:6216cdac377c6b9a040ac1c0404f7284bd13199c0e1bb235f4324627e8898cf5", size = 15347, upload-time = "2024-04-06T02:13:37.412Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "types-s3transfer"
|
||||
version = "0.14.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/9b/8913198b7fc700acc1dcb84827137bb2922052e43dde0f4fb0ed2dc6f118/types_s3transfer-0.14.0.tar.gz", hash = "sha256:17f800a87c7eafab0434e9d87452c809c290ae906c2024c24261c564479e9c95", size = 14218, upload-time = "2025-10-11T21:11:27.892Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/92/c3/4dfb2e87c15ca582b7d956dfb7e549de1d005c758eb9a305e934e1b83fda/types_s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:108134854069a38b048e9b710b9b35904d22a9d0f37e4e1889c2e6b58e5b3253", size = 19697, upload-time = "2025-10-11T21:11:26.749Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "types-urllib3"
|
||||
version = "1.26.25.14"
|
||||
@@ -8877,61 +8752,61 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "websockets"
|
||||
version = "14.2"
|
||||
version = "15.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/54/8359678c726243d19fae38ca14a334e740782336c9f19700858c4eb64a1e/websockets-14.2.tar.gz", hash = "sha256:5059ed9c54945efb321f097084b4c7e52c246f2c869815876a69d1efc4ad6eb5", size = 164394, upload-time = "2025-01-19T21:00:56.431Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/28/fa/76607eb7dcec27b2d18d63f60a32e60e2b8629780f343bb83a4dbb9f4350/websockets-14.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e8179f95323b9ab1c11723e5d91a89403903f7b001828161b480a7810b334885", size = 163089, upload-time = "2025-01-19T20:58:43.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/00/ad2246b5030575b79e7af0721810fdaecaf94c4b2625842ef7a756fa06dd/websockets-14.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0d8c3e2cdb38f31d8bd7d9d28908005f6fa9def3324edb9bf336d7e4266fd397", size = 160741, upload-time = "2025-01-19T20:58:45.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/f7/60f10924d333a28a1ff3fcdec85acf226281331bdabe9ad74947e1b7fc0a/websockets-14.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:714a9b682deb4339d39ffa674f7b674230227d981a37d5d174a4a83e3978a610", size = 160996, upload-time = "2025-01-19T20:58:47.563Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/7c/c655789cf78648c01ac6ecbe2d6c18f91b75bdc263ffee4d08ce628d12f0/websockets-14.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2e53c72052f2596fb792a7acd9704cbc549bf70fcde8a99e899311455974ca3", size = 169974, upload-time = "2025-01-19T20:58:51.023Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/5b/013ed8b4611857ac92ac631079c08d9715b388bd1d88ec62e245f87a39df/websockets-14.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e3fbd68850c837e57373d95c8fe352203a512b6e49eaae4c2f4088ef8cf21980", size = 168985, upload-time = "2025-01-19T20:58:52.698Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/33/aa3e32fd0df213a5a442310754fe3f89dd87a0b8e5b4e11e0991dd3bcc50/websockets-14.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b27ece32f63150c268593d5fdb82819584831a83a3f5809b7521df0685cd5d8", size = 169297, upload-time = "2025-01-19T20:58:54.898Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/17/dae0174883d6399f57853ac44abf5f228eaba86d98d160f390ffabc19b6e/websockets-14.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4daa0faea5424d8713142b33825fff03c736f781690d90652d2c8b053345b0e7", size = 169677, upload-time = "2025-01-19T20:58:56.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/e2/0375af7ac00169b98647c804651c515054b34977b6c1354f1458e4116c1e/websockets-14.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:bc63cee8596a6ec84d9753fd0fcfa0452ee12f317afe4beae6b157f0070c6c7f", size = 169089, upload-time = "2025-01-19T20:58:58.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/8d/80f71d2a351a44b602859af65261d3dde3a0ce4e76cf9383738a949e0cc3/websockets-14.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7a570862c325af2111343cc9b0257b7119b904823c675b22d4ac547163088d0d", size = 169026, upload-time = "2025-01-19T20:59:01.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/97/173b1fa6052223e52bb4054a141433ad74931d94c575e04b654200b98ca4/websockets-14.2-cp310-cp310-win32.whl", hash = "sha256:75862126b3d2d505e895893e3deac0a9339ce750bd27b4ba515f008b5acf832d", size = 163967, upload-time = "2025-01-19T20:59:02.662Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/5b/2fcf60f38252a4562b28b66077e0d2b48f91fef645d5f78874cd1dec807b/websockets-14.2-cp310-cp310-win_amd64.whl", hash = "sha256:cc45afb9c9b2dc0852d5c8b5321759cf825f82a31bfaf506b65bf4668c96f8b2", size = 164413, upload-time = "2025-01-19T20:59:05.071Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/b6/504695fb9a33df0ca56d157f5985660b5fc5b4bf8c78f121578d2d653392/websockets-14.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3bdc8c692c866ce5fefcaf07d2b55c91d6922ac397e031ef9b774e5b9ea42166", size = 163088, upload-time = "2025-01-19T20:59:06.435Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/26/ebfb8f6abe963c795122439c6433c4ae1e061aaedfc7eff32d09394afbae/websockets-14.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c93215fac5dadc63e51bcc6dceca72e72267c11def401d6668622b47675b097f", size = 160745, upload-time = "2025-01-19T20:59:09.109Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/c6/1435ad6f6dcbff80bb95e8986704c3174da8866ddb751184046f5c139ef6/websockets-14.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1c9b6535c0e2cf8a6bf938064fb754aaceb1e6a4a51a80d884cd5db569886910", size = 160995, upload-time = "2025-01-19T20:59:12.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/63/900c27cfe8be1a1f2433fc77cd46771cf26ba57e6bdc7cf9e63644a61863/websockets-14.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a52a6d7cf6938e04e9dceb949d35fbdf58ac14deea26e685ab6368e73744e4c", size = 170543, upload-time = "2025-01-19T20:59:15.026Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/8b/bec2bdba92af0762d42d4410593c1d7d28e9bfd952c97a3729df603dc6ea/websockets-14.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9f05702e93203a6ff5226e21d9b40c037761b2cfb637187c9802c10f58e40473", size = 169546, upload-time = "2025-01-19T20:59:17.156Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/a9/37531cb5b994f12a57dec3da2200ef7aadffef82d888a4c29a0d781568e4/websockets-14.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22441c81a6748a53bfcb98951d58d1af0661ab47a536af08920d129b4d1c3473", size = 169911, upload-time = "2025-01-19T20:59:18.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/d5/a6eadba2ed9f7e65d677fec539ab14a9b83de2b484ab5fe15d3d6d208c28/websockets-14.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd9b868d78b194790e6236d9cbc46d68aba4b75b22497eb4ab64fa640c3af56", size = 170183, upload-time = "2025-01-19T20:59:20.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/57/a338ccb00d1df881c1d1ee1f2a20c9c1b5b29b51e9e0191ee515d254fea6/websockets-14.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1a5a20d5843886d34ff8c57424cc65a1deda4375729cbca4cb6b3353f3ce4142", size = 169623, upload-time = "2025-01-19T20:59:22.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/22/e5f7c33db0cb2c1d03b79fd60d189a1da044e2661f5fd01d629451e1db89/websockets-14.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:34277a29f5303d54ec6468fb525d99c99938607bc96b8d72d675dee2b9f5bf1d", size = 169583, upload-time = "2025-01-19T20:59:23.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/2e/2b4662237060063a22e5fc40d46300a07142afe30302b634b4eebd717c07/websockets-14.2-cp311-cp311-win32.whl", hash = "sha256:02687db35dbc7d25fd541a602b5f8e451a238ffa033030b172ff86a93cb5dc2a", size = 163969, upload-time = "2025-01-19T20:59:26.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/a5/0cda64e1851e73fc1ecdae6f42487babb06e55cb2f0dc8904b81d8ef6857/websockets-14.2-cp311-cp311-win_amd64.whl", hash = "sha256:862e9967b46c07d4dcd2532e9e8e3c2825e004ffbf91a5ef9dde519ee2effb0b", size = 164408, upload-time = "2025-01-19T20:59:28.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/81/04f7a397653dc8bec94ddc071f34833e8b99b13ef1a3804c149d59f92c18/websockets-14.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1f20522e624d7ffbdbe259c6b6a65d73c895045f76a93719aa10cd93b3de100c", size = 163096, upload-time = "2025-01-19T20:59:29.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/c5/de30e88557e4d70988ed4d2eabd73fd3e1e52456b9f3a4e9564d86353b6d/websockets-14.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:647b573f7d3ada919fd60e64d533409a79dcf1ea21daeb4542d1d996519ca967", size = 160758, upload-time = "2025-01-19T20:59:32.095Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/8c/d130d668781f2c77d106c007b6c6c1d9db68239107c41ba109f09e6c218a/websockets-14.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6af99a38e49f66be5a64b1e890208ad026cda49355661549c507152113049990", size = 160995, upload-time = "2025-01-19T20:59:33.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/bc/f6678a0ff17246df4f06765e22fc9d98d1b11a258cc50c5968b33d6742a1/websockets-14.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:091ab63dfc8cea748cc22c1db2814eadb77ccbf82829bac6b2fbe3401d548eda", size = 170815, upload-time = "2025-01-19T20:59:35.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/b2/8070cb970c2e4122a6ef38bc5b203415fd46460e025652e1ee3f2f43a9a3/websockets-14.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b374e8953ad477d17e4851cdc66d83fdc2db88d9e73abf755c94510ebddceb95", size = 169759, upload-time = "2025-01-19T20:59:38.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/da/72f7caabd94652e6eb7e92ed2d3da818626e70b4f2b15a854ef60bf501ec/websockets-14.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a39d7eceeea35db85b85e1169011bb4321c32e673920ae9c1b6e0978590012a3", size = 170178, upload-time = "2025-01-19T20:59:40.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/e0/812725b6deca8afd3a08a2e81b3c4c120c17f68c9b84522a520b816cda58/websockets-14.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0a6f3efd47ffd0d12080594f434faf1cd2549b31e54870b8470b28cc1d3817d9", size = 170453, upload-time = "2025-01-19T20:59:41.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/d3/8275dbc231e5ba9bb0c4f93144394b4194402a7a0c8ffaca5307a58ab5e3/websockets-14.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:065ce275e7c4ffb42cb738dd6b20726ac26ac9ad0a2a48e33ca632351a737267", size = 169830, upload-time = "2025-01-19T20:59:44.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/ae/e7d1a56755ae15ad5a94e80dd490ad09e345365199600b2629b18ee37bc7/websockets-14.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e9d0e53530ba7b8b5e389c02282f9d2aa47581514bd6049d3a7cffe1385cf5fe", size = 169824, upload-time = "2025-01-19T20:59:46.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/32/88ccdd63cb261e77b882e706108d072e4f1c839ed723bf91a3e1f216bf60/websockets-14.2-cp312-cp312-win32.whl", hash = "sha256:20e6dd0984d7ca3037afcb4494e48c74ffb51e8013cac71cf607fffe11df7205", size = 163981, upload-time = "2025-01-19T20:59:49.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/7d/32cdb77990b3bdc34a306e0a0f73a1275221e9a66d869f6ff833c95b56ef/websockets-14.2-cp312-cp312-win_amd64.whl", hash = "sha256:44bba1a956c2c9d268bdcdf234d5e5ff4c9b6dc3e300545cbe99af59dda9dcce", size = 164421, upload-time = "2025-01-19T20:59:50.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/94/4f9b55099a4603ac53c2912e1f043d6c49d23e94dd82a9ce1eb554a90215/websockets-14.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6f1372e511c7409a542291bce92d6c83320e02c9cf392223272287ce55bc224e", size = 163102, upload-time = "2025-01-19T20:59:52.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/b7/7484905215627909d9a79ae07070057afe477433fdacb59bf608ce86365a/websockets-14.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4da98b72009836179bb596a92297b1a61bb5a830c0e483a7d0766d45070a08ad", size = 160766, upload-time = "2025-01-19T20:59:54.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/a4/edb62efc84adb61883c7d2c6ad65181cb087c64252138e12d655989eec05/websockets-14.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8a86a269759026d2bde227652b87be79f8a734e582debf64c9d302faa1e9f03", size = 160998, upload-time = "2025-01-19T20:59:56.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/79/036d320dc894b96af14eac2529967a6fc8b74f03b83c487e7a0e9043d842/websockets-14.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:86cf1aaeca909bf6815ea714d5c5736c8d6dd3a13770e885aafe062ecbd04f1f", size = 170780, upload-time = "2025-01-19T20:59:58.085Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/75/5737d21ee4dd7e4b9d487ee044af24a935e36a9ff1e1419d684feedcba71/websockets-14.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b0f6c3ba3b1240f602ebb3971d45b02cc12bd1845466dd783496b3b05783a5", size = 169717, upload-time = "2025-01-19T20:59:59.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/3c/bf9b2c396ed86a0b4a92ff4cdaee09753d3ee389be738e92b9bbd0330b64/websockets-14.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:669c3e101c246aa85bc8534e495952e2ca208bd87994650b90a23d745902db9a", size = 170155, upload-time = "2025-01-19T21:00:01.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/2d/83a5aca7247a655b1da5eb0ee73413abd5c3a57fc8b92915805e6033359d/websockets-14.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eabdb28b972f3729348e632ab08f2a7b616c7e53d5414c12108c29972e655b20", size = 170495, upload-time = "2025-01-19T21:00:04.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/dd/699238a92761e2f943885e091486378813ac8f43e3c84990bc394c2be93e/websockets-14.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2066dc4cbcc19f32c12a5a0e8cc1b7ac734e5b64ac0a325ff8353451c4b15ef2", size = 169880, upload-time = "2025-01-19T21:00:05.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/c9/67a8f08923cf55ce61aadda72089e3ed4353a95a3a4bc8bf42082810e580/websockets-14.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ab95d357cd471df61873dadf66dd05dd4709cae001dd6342edafc8dc6382f307", size = 169856, upload-time = "2025-01-19T21:00:07.192Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/b1/1ffdb2680c64e9c3921d99db460546194c40d4acbef999a18c37aa4d58a3/websockets-14.2-cp313-cp313-win32.whl", hash = "sha256:a9e72fb63e5f3feacdcf5b4ff53199ec8c18d66e325c34ee4c551ca748623bbc", size = 163974, upload-time = "2025-01-19T21:00:08.698Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/13/8b7fc4cb551b9cfd9890f0fd66e53c18a06240319915533b033a56a3d520/websockets-14.2-cp313-cp313-win_amd64.whl", hash = "sha256:b439ea828c4ba99bb3176dc8d9b933392a2413c0f6b149fdcba48393f573377f", size = 164420, upload-time = "2025-01-19T21:00:10.182Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/3d/91d3d2bb1325cd83e8e2c02d0262c7d4426dc8fa0831ef1aa4d6bf2041af/websockets-14.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:d7d9cafbccba46e768be8a8ad4635fa3eae1ffac4c6e7cb4eb276ba41297ed29", size = 160773, upload-time = "2025-01-19T21:00:32.225Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/7c/cdedadfef7381939577858b1b5718a4ab073adbb584e429dd9d9dc9bfe16/websockets-14.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:c76193c1c044bd1e9b3316dcc34b174bbf9664598791e6fb606d8d29000e070c", size = 161007, upload-time = "2025-01-19T21:00:33.784Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/35/7a20a3c450b27c04e50fbbfc3dfb161ed8e827b2a26ae31c4b59b018b8c6/websockets-14.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fd475a974d5352390baf865309fe37dec6831aafc3014ffac1eea99e84e83fc2", size = 162264, upload-time = "2025-01-19T21:00:35.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/9c/e3f9600564b0c813f2448375cf28b47dc42c514344faed3a05d71fb527f9/websockets-14.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2c6c0097a41968b2e2b54ed3424739aab0b762ca92af2379f152c1aef0187e1c", size = 161873, upload-time = "2025-01-19T21:00:37.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/37/260f189b16b2b8290d6ae80c9f96d8b34692cf1bb3475df54c38d3deb57d/websockets-14.2-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d7ff794c8b36bc402f2e07c0b2ceb4a2424147ed4785ff03e2a7af03711d60a", size = 161818, upload-time = "2025-01-19T21:00:38.952Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/1e/e47dedac8bf7140e59aa6a679e850c4df9610ae844d71b6015263ddea37b/websockets-14.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:dec254fcabc7bd488dab64846f588fc5b6fe0d78f641180030f8ea27b76d72c3", size = 164465, upload-time = "2025-01-19T21:00:40.456Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/c8/d529f8a32ce40d98309f4470780631e971a5a842b60aec864833b3615786/websockets-14.2-py3-none-any.whl", hash = "sha256:7a6ceec4ea84469f15cf15807a747e9efe57e369c384fa86e022b3bea679b79b", size = 157416, upload-time = "2025-01-19T21:00:54.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user