Compare commits

..

7 Commits

Author SHA1 Message Date
lorenzejay
b8eb7dd294 fix import 2025-12-21 22:38:24 -08:00
lorenzejay
f39379ddd5 refactor: replace AgentExecutionErrorEvent with TaskFailedEvent for LLM call handling
- Updated Agent class to emit TaskFailedEvent instead of AgentExecutionErrorEvent when LLM calls are blocked.
- Removed unnecessary LLMCallBlockedError handling from CrewAgentExecutor.
- Enhanced test cases to ensure proper exception handling for blocked LLM calls.
- Improved code clarity and consistency in event handling across agent execution.
2025-12-21 22:05:11 -08:00
lorenzejay
05c42791c9 feat: implement LLMCallBlockedError handling in LLM and Agent classes
- Introduced LLMCallBlockedError to manage blocked LLM calls from before_llm_call hooks.
- Updated LLM class to raise LLMCallBlockedError instead of returning a boolean.
- Enhanced Agent class to emit events and handle LLMCallBlockedError during task execution.
- Added error handling in CrewAgentExecutor and agent utilities to gracefully manage blocked calls.
- Updated tests to verify behavior when LLM calls are blocked.
2025-12-21 21:48:26 -08:00
Heitor Carvalho
be70a04153 fix: correct error fetching for workos login polling (#4124)
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Notify Downstream / notify-downstream (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
Build uv cache / build-cache (3.10) (push) Has been cancelled
Build uv cache / build-cache (3.11) (push) Has been cancelled
Build uv cache / build-cache (3.12) (push) Has been cancelled
Build uv cache / build-cache (3.13) (push) Has been cancelled
2025-12-19 20:00:26 -03:00
Greyson LaLonde
0c359f4df8 feat: bump versions to 1.7.2
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Notify Downstream / notify-downstream (push) Has been cancelled
2025-12-19 15:47:00 -05:00
Lucas Gomide
fe288dbe73 Resolving some connection issues (#4129)
* fix: use CREWAI_PLUS_URL env var in precedence over PlusAPI configured value

* feat: bypass TLS certificate verification when calling platform

* test: fix test
2025-12-19 10:15:20 -05:00
Heitor Carvalho
dc63bc2319 chore: remove CREWAI_BASE_URL and fetch url from settings instead
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Notify Downstream / notify-downstream (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
2025-12-18 15:41:38 -03:00
32 changed files with 469 additions and 133 deletions

View File

@@ -12,7 +12,7 @@ dependencies = [
"pytube~=15.0.0",
"requests~=2.32.5",
"docker~=7.1.0",
"crewai==1.7.1",
"crewai==1.7.2",
"lancedb~=0.5.4",
"tiktoken~=0.8.0",
"beautifulsoup4~=4.13.4",

View File

@@ -291,4 +291,4 @@ __all__ = [
"ZapierActionTools",
]
__version__ = "1.7.1"
__version__ = "1.7.2"

View File

@@ -1,5 +1,5 @@
"""Crewai Enterprise Tools."""
import os
import json
import re
from typing import Any, Optional, Union, cast, get_origin
@@ -432,7 +432,11 @@ class CrewAIPlatformActionTool(BaseTool):
payload = cleaned_kwargs
response = requests.post(
url=api_url, headers=headers, json=payload, timeout=60
url=api_url,
headers=headers,
json=payload,
timeout=60,
verify=os.environ.get("CREWAI_FACTORY", "false").lower() != "true",
)
data = response.json()

View File

@@ -1,5 +1,5 @@
from typing import Any
import os
from crewai.tools import BaseTool
import requests
@@ -37,6 +37,7 @@ class CrewaiPlatformToolBuilder:
headers=headers,
timeout=30,
params={"apps": ",".join(self._apps)},
verify=os.environ.get("CREWAI_FACTORY", "false").lower() != "true",
)
response.raise_for_status()
except Exception:

View File

@@ -1,4 +1,6 @@
from typing import Union, get_args, get_origin
from unittest.mock import patch, Mock
import os
from crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool import (
CrewAIPlatformActionTool,
@@ -249,3 +251,109 @@ class TestSchemaProcessing:
result_type = tool._process_schema_type(test_schema, "TestFieldAllOfMixed")
assert result_type is str
class TestCrewAIPlatformActionToolVerify:
"""Test suite for SSL verification behavior based on CREWAI_FACTORY environment variable"""
def setup_method(self):
self.action_schema = {
"function": {
"name": "test_action",
"parameters": {
"properties": {
"test_param": {
"type": "string",
"description": "Test parameter"
}
},
"required": []
}
}
}
def create_test_tool(self):
return CrewAIPlatformActionTool(
description="Test action tool",
action_name="test_action",
action_schema=self.action_schema
)
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"}, clear=True)
@patch("crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool.requests.post")
def test_run_with_ssl_verification_default(self, mock_post):
"""Test that _run uses SSL verification by default when CREWAI_FACTORY is not set"""
mock_response = Mock()
mock_response.ok = True
mock_response.json.return_value = {"result": "success"}
mock_post.return_value = mock_response
tool = self.create_test_tool()
tool._run(test_param="test_value")
mock_post.assert_called_once()
call_args = mock_post.call_args
assert call_args.kwargs["verify"] is True
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token", "CREWAI_FACTORY": "false"}, clear=True)
@patch("crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool.requests.post")
def test_run_with_ssl_verification_factory_false(self, mock_post):
"""Test that _run uses SSL verification when CREWAI_FACTORY is 'false'"""
mock_response = Mock()
mock_response.ok = True
mock_response.json.return_value = {"result": "success"}
mock_post.return_value = mock_response
tool = self.create_test_tool()
tool._run(test_param="test_value")
mock_post.assert_called_once()
call_args = mock_post.call_args
assert call_args.kwargs["verify"] is True
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token", "CREWAI_FACTORY": "FALSE"}, clear=True)
@patch("crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool.requests.post")
def test_run_with_ssl_verification_factory_false_uppercase(self, mock_post):
"""Test that _run uses SSL verification when CREWAI_FACTORY is 'FALSE' (case-insensitive)"""
mock_response = Mock()
mock_response.ok = True
mock_response.json.return_value = {"result": "success"}
mock_post.return_value = mock_response
tool = self.create_test_tool()
tool._run(test_param="test_value")
mock_post.assert_called_once()
call_args = mock_post.call_args
assert call_args.kwargs["verify"] is True
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token", "CREWAI_FACTORY": "true"}, clear=True)
@patch("crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool.requests.post")
def test_run_without_ssl_verification_factory_true(self, mock_post):
"""Test that _run disables SSL verification when CREWAI_FACTORY is 'true'"""
mock_response = Mock()
mock_response.ok = True
mock_response.json.return_value = {"result": "success"}
mock_post.return_value = mock_response
tool = self.create_test_tool()
tool._run(test_param="test_value")
mock_post.assert_called_once()
call_args = mock_post.call_args
assert call_args.kwargs["verify"] is False
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token", "CREWAI_FACTORY": "TRUE"}, clear=True)
@patch("crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool.requests.post")
def test_run_without_ssl_verification_factory_true_uppercase(self, mock_post):
"""Test that _run disables SSL verification when CREWAI_FACTORY is 'TRUE' (case-insensitive)"""
mock_response = Mock()
mock_response.ok = True
mock_response.json.return_value = {"result": "success"}
mock_post.return_value = mock_response
tool = self.create_test_tool()
tool._run(test_param="test_value")
mock_post.assert_called_once()
call_args = mock_post.call_args
assert call_args.kwargs["verify"] is False

View File

@@ -258,3 +258,98 @@ class TestCrewaiPlatformToolBuilder(unittest.TestCase):
assert "simple_string" in description_text
assert "nested_object" in description_text
assert "array_prop" in description_text
class TestCrewaiPlatformToolBuilderVerify(unittest.TestCase):
"""Test suite for SSL verification behavior in CrewaiPlatformToolBuilder"""
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"}, clear=True)
@patch(
"crewai_tools.tools.crewai_platform_tools.crewai_platform_tool_builder.requests.get"
)
def test_fetch_actions_with_ssl_verification_default(self, mock_get):
"""Test that _fetch_actions uses SSL verification by default when CREWAI_FACTORY is not set"""
mock_response = Mock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = {"actions": {}}
mock_get.return_value = mock_response
builder = CrewaiPlatformToolBuilder(apps=["github"])
builder._fetch_actions()
mock_get.assert_called_once()
call_args = mock_get.call_args
assert call_args.kwargs["verify"] is True
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token", "CREWAI_FACTORY": "false"}, clear=True)
@patch(
"crewai_tools.tools.crewai_platform_tools.crewai_platform_tool_builder.requests.get"
)
def test_fetch_actions_with_ssl_verification_factory_false(self, mock_get):
"""Test that _fetch_actions uses SSL verification when CREWAI_FACTORY is 'false'"""
mock_response = Mock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = {"actions": {}}
mock_get.return_value = mock_response
builder = CrewaiPlatformToolBuilder(apps=["github"])
builder._fetch_actions()
mock_get.assert_called_once()
call_args = mock_get.call_args
assert call_args.kwargs["verify"] is True
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token", "CREWAI_FACTORY": "FALSE"}, clear=True)
@patch(
"crewai_tools.tools.crewai_platform_tools.crewai_platform_tool_builder.requests.get"
)
def test_fetch_actions_with_ssl_verification_factory_false_uppercase(self, mock_get):
"""Test that _fetch_actions uses SSL verification when CREWAI_FACTORY is 'FALSE' (case-insensitive)"""
mock_response = Mock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = {"actions": {}}
mock_get.return_value = mock_response
builder = CrewaiPlatformToolBuilder(apps=["github"])
builder._fetch_actions()
mock_get.assert_called_once()
call_args = mock_get.call_args
assert call_args.kwargs["verify"] is True
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token", "CREWAI_FACTORY": "true"}, clear=True)
@patch(
"crewai_tools.tools.crewai_platform_tools.crewai_platform_tool_builder.requests.get"
)
def test_fetch_actions_without_ssl_verification_factory_true(self, mock_get):
"""Test that _fetch_actions disables SSL verification when CREWAI_FACTORY is 'true'"""
mock_response = Mock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = {"actions": {}}
mock_get.return_value = mock_response
builder = CrewaiPlatformToolBuilder(apps=["github"])
builder._fetch_actions()
mock_get.assert_called_once()
call_args = mock_get.call_args
assert call_args.kwargs["verify"] is False
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token", "CREWAI_FACTORY": "TRUE"}, clear=True)
@patch(
"crewai_tools.tools.crewai_platform_tools.crewai_platform_tool_builder.requests.get"
)
def test_fetch_actions_without_ssl_verification_factory_true_uppercase(self, mock_get):
"""Test that _fetch_actions disables SSL verification when CREWAI_FACTORY is 'TRUE' (case-insensitive)"""
mock_response = Mock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = {"actions": {}}
mock_get.return_value = mock_response
builder = CrewaiPlatformToolBuilder(apps=["github"])
builder._fetch_actions()
mock_get.assert_called_once()
call_args = mock_get.call_args
assert call_args.kwargs["verify"] is False

View File

@@ -49,7 +49,7 @@ Repository = "https://github.com/crewAIInc/crewAI"
[project.optional-dependencies]
tools = [
"crewai-tools==1.7.1",
"crewai-tools==1.7.2",
]
embeddings = [
"tiktoken~=0.8.0"

View File

@@ -40,7 +40,7 @@ def _suppress_pydantic_deprecation_warnings() -> None:
_suppress_pydantic_deprecation_warnings()
__version__ = "1.7.1"
__version__ = "1.7.2"
_telemetry_submitted = False

View File

@@ -44,6 +44,8 @@ from crewai.events.types.memory_events import (
MemoryRetrievalCompletedEvent,
MemoryRetrievalStartedEvent,
)
from crewai.events.types.task_events import TaskFailedEvent
from crewai.hooks import LLMCallBlockedError
from crewai.knowledge.knowledge import Knowledge
from crewai.knowledge.source.base_knowledge_source import BaseKnowledgeSource
from crewai.lite_agent import LiteAgent
@@ -409,6 +411,15 @@ class Agent(BaseAgent):
),
)
raise e
if isinstance(e, LLMCallBlockedError):
crewai_event_bus.emit(
self,
event=TaskFailedEvent( # type: ignore[no-untyped-call]
task=task,
error=str(e),
),
)
raise e
self._times_executed += 1
if self._times_executed > self.max_retry_limit:
crewai_event_bus.emit(
@@ -615,6 +626,15 @@ class Agent(BaseAgent):
),
)
raise e
if isinstance(e, LLMCallBlockedError):
crewai_event_bus.emit(
self,
event=TaskFailedEvent( # type: ignore[no-untyped-call]
task=task,
error=str(e),
),
)
raise e
self._times_executed += 1
if self._times_executed > self.max_retry_limit:
crewai_event_bus.emit(

View File

@@ -34,6 +34,7 @@ from crewai.utilities.agent_utils import (
get_llm_response,
handle_agent_action_core,
handle_context_length,
handle_llm_call_blocked_error,
handle_max_iterations_exceeded,
handle_output_parser_exception,
handle_unknown_error,
@@ -284,7 +285,6 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
log_error_after=self.log_error_after,
printer=self._printer,
)
except Exception as e:
if e.__class__.__module__.startswith("litellm"):
# Do not retry on litellm errors

View File

@@ -149,7 +149,9 @@ class AuthenticationCommand:
return
if token_data["error"] not in ("authorization_pending", "slow_down"):
raise requests.HTTPError(token_data["error_description"])
raise requests.HTTPError(
token_data.get("error_description") or token_data.get("error")
)
time.sleep(device_code_data["interval"])
attempts += 1

View File

@@ -1,6 +1,6 @@
from typing import Any
from urllib.parse import urljoin
import os
import requests
from crewai.cli.config import Settings
@@ -33,9 +33,7 @@ class PlusAPI:
if settings.org_uuid:
self.headers["X-Crewai-Organization-Id"] = settings.org_uuid
self.base_url = (
str(settings.enterprise_base_url) or DEFAULT_CREWAI_ENTERPRISE_URL
)
self.base_url = os.getenv("CREWAI_PLUS_URL") or str(settings.enterprise_base_url) or DEFAULT_CREWAI_ENTERPRISE_URL
def _make_request(
self, method: str, endpoint: str, **kwargs: Any

View File

@@ -5,7 +5,7 @@ description = "{{name}} using crewAI"
authors = [{ name = "Your Name", email = "you@example.com" }]
requires-python = ">=3.10,<3.14"
dependencies = [
"crewai[tools]==1.7.1"
"crewai[tools]==1.7.2"
]
[project.scripts]

View File

@@ -5,7 +5,7 @@ description = "{{name}} using crewAI"
authors = [{ name = "Your Name", email = "you@example.com" }]
requires-python = ">=3.10,<3.14"
dependencies = [
"crewai[tools]==1.7.1"
"crewai[tools]==1.7.2"
]
[project.scripts]

View File

@@ -12,6 +12,7 @@ from rich.console import Console
from crewai.cli import git
from crewai.cli.command import BaseCommand, PlusAPIMixin
from crewai.cli.config import Settings
from crewai.cli.constants import DEFAULT_CREWAI_ENTERPRISE_URL
from crewai.cli.utils import (
build_env_with_tool_repository_credentials,
extract_available_exports,
@@ -131,10 +132,13 @@ class ToolCommand(BaseCommand, PlusAPIMixin):
self._validate_response(publish_response)
published_handle = publish_response.json()["handle"]
settings = Settings()
base_url = settings.enterprise_base_url or DEFAULT_CREWAI_ENTERPRISE_URL
console.print(
f"Successfully published `{published_handle}` ({project_version}).\n\n"
+ "⚠️ Security checks are running in the background. Your tool will be available once these are complete.\n"
+ f"You can monitor the status or access your tool here:\nhttps://app.crewai.com/crewai_plus/tools/{published_handle}",
+ f"You can monitor the status or access your tool here:\n{base_url}/crewai_plus/tools/{published_handle}",
style="bold green",
)

View File

@@ -9,6 +9,8 @@ from rich.console import Console
from rich.panel import Panel
from crewai.cli.authentication.token import AuthError, get_auth_token
from crewai.cli.config import Settings
from crewai.cli.constants import DEFAULT_CREWAI_ENTERPRISE_URL
from crewai.cli.plus_api import PlusAPI
from crewai.cli.version import get_crewai_version
from crewai.events.listeners.tracing.types import TraceEvent
@@ -16,7 +18,6 @@ from crewai.events.listeners.tracing.utils import (
is_tracing_enabled_in_context,
should_auto_collect_first_time_traces,
)
from crewai.utilities.constants import CREWAI_BASE_URL
logger = getLogger(__name__)
@@ -326,10 +327,12 @@ class TraceBatchManager:
if response.status_code == 200:
access_code = response.json().get("access_code", None)
console = Console()
settings = Settings()
base_url = settings.enterprise_base_url or DEFAULT_CREWAI_ENTERPRISE_URL
return_link = (
f"{CREWAI_BASE_URL}/crewai_plus/trace_batches/{self.trace_batch_id}"
f"{base_url}/crewai_plus/trace_batches/{self.trace_batch_id}"
if not self.is_current_batch_ephemeral and access_code is None
else f"{CREWAI_BASE_URL}/crewai_plus/ephemeral_trace_batches/{self.trace_batch_id}?access_code={access_code}"
else f"{base_url}/crewai_plus/ephemeral_trace_batches/{self.trace_batch_id}?access_code={access_code}"
)
if self.is_current_batch_ephemeral:

View File

@@ -1,7 +1,7 @@
"""Trace collection listener for orchestrating trace collection."""
import os
from typing import Any, ClassVar, cast
from typing import Any, ClassVar
import uuid
from typing_extensions import Self
@@ -9,7 +9,6 @@ from typing_extensions import Self
from crewai.cli.authentication.token import AuthError, get_auth_token
from crewai.cli.version import get_crewai_version
from crewai.events.base_event_listener import BaseEventListener
from crewai.events.base_events import BaseEvent
from crewai.events.event_bus import CrewAIEventsBus
from crewai.events.listeners.tracing.first_time_trace_handler import (
FirstTimeTraceHandler,
@@ -84,7 +83,6 @@ from crewai.events.types.tool_usage_events import (
ToolUsageStartedEvent,
)
from crewai.events.utils.console_formatter import ConsoleFormatter
from crewai.task import Task
class TraceCollectionListener(BaseEventListener):
@@ -93,6 +91,8 @@ class TraceCollectionListener(BaseEventListener):
complex_events: ClassVar[list[str]] = [
"task_started",
"task_completed",
"llm_call_started",
"llm_call_completed",
"agent_execution_started",
"agent_execution_completed",
]
@@ -567,89 +567,79 @@ class TraceCollectionListener(BaseEventListener):
self.batch_manager.end_event_processing()
def _create_trace_event(
self, event_type: str, source: Any, event: type[BaseEvent]
self, event_type: str, source: Any, event: Any
) -> TraceEvent:
"""Create a trace event"""
trace_event = TraceEvent(
type=event_type,
timestamp=event.timestamp.isoformat(),
)
if hasattr(event, "timestamp") and event.timestamp:
trace_event = TraceEvent(
type=event_type,
timestamp=event.timestamp.isoformat(),
)
else:
trace_event = TraceEvent(
type=event_type,
)
trace_event.event_data = self._build_event_data(event_type, event, source)
return trace_event
def _build_event_data(
self, event_type: str, event: type[BaseEvent], source: Any
self, event_type: str, event: Any, source: Any
) -> dict[str, Any]:
"""Build event data"""
event_data = None
if event_type not in self.complex_events:
event_data = safe_serialize_to_dict(event)
elif event_type == "task_started":
task_started_event: TaskStartedEvent = cast(TaskStartedEvent, event)
event_data = {
"task_description": cast(Task, task_started_event.task).description,
"expected_output": cast(Task, task_started_event.task).expected_output,
"task_name": cast(Task, task_started_event.task).name
or cast(Task, task_started_event.task).description,
"context": task_started_event.context,
return safe_serialize_to_dict(event)
if event_type == "task_started":
return {
"task_description": event.task.description,
"expected_output": event.task.expected_output,
"task_name": event.task.name or event.task.description,
"context": event.context,
"agent_role": source.agent.role,
"task_id": str(cast(Task, task_started_event.task).id),
"task_id": str(event.task.id),
}
elif event_type == "task_completed":
task_completed_event: TaskCompletedEvent = cast(TaskCompletedEvent, event)
event_data = {
"task_description": cast(Task, task_completed_event.task).description
if cast(Task, task_completed_event.task)
if event_type == "task_completed":
return {
"task_description": event.task.description if event.task else None,
"task_name": event.task.name or event.task.description
if event.task
else None,
"task_name": cast(Task, task_completed_event.task).name
or cast(Task, task_completed_event.task).description
if task_completed_event.task
else None,
"task_id": str(cast(Task, task_completed_event.task).id)
if cast(Task, task_completed_event.task)
else None,
"output_raw": task_completed_event.output.raw
if task_completed_event.output
else None,
"output_format": str(task_completed_event.output.output_format)
if task_completed_event.output
else None,
"agent_role": task_completed_event.output.agent
if task_completed_event.output
"task_id": str(event.task.id) if event.task else None,
"output_raw": event.output.raw if event.output else None,
"output_format": str(event.output.output_format)
if event.output
else None,
"agent_role": event.output.agent if event.output else None,
}
elif event_type == "agent_execution_started":
agent_execution_started_event: AgentExecutionStartedEvent = cast(
AgentExecutionStartedEvent, event
if event_type == "agent_execution_started":
return {
"agent_role": event.agent.role,
"agent_goal": event.agent.goal,
"agent_backstory": event.agent.backstory,
}
if event_type == "agent_execution_completed":
return {
"agent_role": event.agent.role,
"agent_goal": event.agent.goal,
"agent_backstory": event.agent.backstory,
}
if event_type == "llm_call_started":
event_data = safe_serialize_to_dict(event)
event_data["task_name"] = (
event.task_name or event.task_description
if hasattr(event, "task_name") and event.task_name
else None
)
event_data = {
"agent_role": agent_execution_started_event.agent.role,
"agent_goal": agent_execution_started_event.agent.goal,
"agent_backstory": agent_execution_started_event.agent.backstory,
}
elif event_type == "agent_execution_completed":
agent_execution_completed_event: AgentExecutionCompletedEvent = cast(
AgentExecutionCompletedEvent, event
)
event_data = {
"agent_role": agent_execution_completed_event.agent.role,
"agent_goal": agent_execution_completed_event.agent.goal,
"agent_backstory": agent_execution_completed_event.agent.backstory,
}
else:
event_data = {
"event_type": event_type,
"event": safe_serialize_to_dict(event),
"source": source,
}
return event_data
if event_type == "llm_call_completed":
return safe_serialize_to_dict(event)
if "timestamp" not in event_data.keys():
event_data["timestamp"] = event.timestamp.isoformat()
return event_data
return {
"event_type": event_type,
"event": safe_serialize_to_dict(event),
"source": source,
}
def _show_tracing_disabled_message(self) -> None:
"""Show a message when tracing is disabled."""

View File

@@ -9,7 +9,6 @@ class TaskStartedEvent(BaseEvent):
type: str = "task_started"
context: str | None
# TODO: Type this correctly with 'Task' type, currently impossible due to circular dependency
task: Any | None = None
def __init__(self, **data):

View File

@@ -7,6 +7,7 @@ from crewai.hooks.decorators import (
before_tool_call,
)
from crewai.hooks.llm_hooks import (
LLMCallBlockedError,
LLMCallHookContext,
clear_after_llm_call_hooks,
clear_all_llm_call_hooks,
@@ -74,6 +75,8 @@ def clear_all_global_hooks() -> dict[str, tuple[int, int]]:
__all__ = [
# Exceptions
"LLMCallBlockedError",
# Context classes
"LLMCallHookContext",
"ToolCallHookContext",

View File

@@ -14,6 +14,14 @@ if TYPE_CHECKING:
from crewai.utilities.types import LLMMessage
class LLMCallBlockedError(Exception):
"""Raised when a before_llm_call hook blocks the LLM call.
This exception is intentionally NOT retried by the agent,
as it represents an intentional block by the hook.
"""
class LLMCallHookContext:
"""Context object passed to LLM call hooks.
@@ -131,6 +139,7 @@ class LLMCallHookContext:
... if response.lower() == "no":
... print("LLM call skipped by user")
"""
# from crewai.events.event_listener import event_listener
printer = Printer()
event_listener.formatter.pause_live_updates()

View File

@@ -1645,8 +1645,7 @@ class LLM(BaseLLM):
msg_role: Literal["assistant"] = "assistant"
message["role"] = msg_role
if not self._invoke_before_llm_call_hooks(messages, from_agent):
raise ValueError("LLM call blocked by before_llm_call hook")
self._invoke_before_llm_call_hooks(messages, from_agent)
# --- 5) Set up callbacks if provided
with suppress_warnings():

View File

@@ -591,7 +591,7 @@ class BaseLLM(ABC):
self,
messages: list[LLMMessage],
from_agent: Agent | None = None,
) -> bool:
) -> None:
"""Invoke before_llm_call hooks for direct LLM calls (no agent context).
This method should be called by native provider implementations before
@@ -601,20 +601,19 @@ class BaseLLM(ABC):
messages: The messages being sent to the LLM
from_agent: The agent making the call (None for direct calls)
Returns:
True if LLM call should proceed, False if blocked by hook
Raises:
LLMCallBlockedError: If any hook returns False to block the LLM call.
Example:
>>> # In a native provider's call() method:
>>> if from_agent is None and not self._invoke_before_llm_call_hooks(
... messages, from_agent
... ):
... raise ValueError("LLM call blocked by hook")
>>> if from_agent is None:
... self._invoke_before_llm_call_hooks(messages, from_agent)
"""
# Only invoke hooks for direct calls (no agent context)
if from_agent is not None:
return True
return
from crewai.hooks import LLMCallBlockedError
from crewai.hooks.llm_hooks import (
LLMCallHookContext,
get_before_llm_call_hooks,
@@ -623,7 +622,7 @@ class BaseLLM(ABC):
before_hooks = get_before_llm_call_hooks()
if not before_hooks:
return True
return
hook_context = LLMCallHookContext(
executor=None,
@@ -643,15 +642,17 @@ class BaseLLM(ABC):
content="LLM call blocked by before_llm_call hook",
color="yellow",
)
return False
raise LLMCallBlockedError(
"LLM call blocked by before_llm_call hook"
)
except LLMCallBlockedError:
raise
except Exception as e:
printer.print(
content=f"Error in before_llm_call hook: {e}",
color="yellow",
)
return True
def _invoke_after_llm_call_hooks(
self,
messages: list[LLMMessage],

View File

@@ -5,7 +5,6 @@ import logging
import os
from typing import TYPE_CHECKING, Any, Literal, cast
from anthropic.types import ThinkingBlock
from pydantic import BaseModel
from crewai.events.types.llm_events import LLMCallType
@@ -197,8 +196,7 @@ class AnthropicCompletion(BaseLLM):
messages
)
if not self._invoke_before_llm_call_hooks(formatted_messages, from_agent):
raise ValueError("LLM call blocked by before_llm_call hook")
self._invoke_before_llm_call_hooks(formatted_messages, from_agent)
# Prepare completion parameters
completion_params = self._prepare_completion_params(

View File

@@ -302,8 +302,7 @@ class AzureCompletion(BaseLLM):
# Format messages for Azure
formatted_messages = self._format_messages_for_azure(messages)
if not self._invoke_before_llm_call_hooks(formatted_messages, from_agent):
raise ValueError("LLM call blocked by before_llm_call hook")
self._invoke_before_llm_call_hooks(formatted_messages, from_agent)
# Prepare completion parameters
completion_params = self._prepare_completion_params(

View File

@@ -315,10 +315,9 @@ class BedrockCompletion(BaseLLM):
messages
)
if not self._invoke_before_llm_call_hooks(
self._invoke_before_llm_call_hooks(
cast(list[LLMMessage], formatted_messages), from_agent
):
raise ValueError("LLM call blocked by before_llm_call hook")
)
# Prepare request body
body: BedrockConverseRequestBody = {

View File

@@ -250,8 +250,7 @@ class GeminiCompletion(BaseLLM):
messages_for_hooks = self._convert_contents_to_dict(formatted_content)
if not self._invoke_before_llm_call_hooks(messages_for_hooks, from_agent):
raise ValueError("LLM call blocked by before_llm_call hook")
self._invoke_before_llm_call_hooks(messages_for_hooks, from_agent)
config = self._prepare_generation_config(
system_instruction, tools, response_model

View File

@@ -190,8 +190,7 @@ class OpenAICompletion(BaseLLM):
formatted_messages = self._format_messages(messages)
if not self._invoke_before_llm_call_hooks(formatted_messages, from_agent):
raise ValueError("LLM call blocked by before_llm_call hook")
self._invoke_before_llm_call_hooks(formatted_messages, from_agent)
completion_params = self._prepare_completion_params(
messages=formatted_messages, tools=tools

View File

@@ -16,6 +16,7 @@ from crewai.agents.parser import (
parse,
)
from crewai.cli.config import Settings
from crewai.hooks import LLMCallBlockedError
from crewai.llms.base_llm import BaseLLM
from crewai.tools import BaseTool as CrewAITool
from crewai.tools.base_tool import BaseTool
@@ -260,8 +261,7 @@ def get_llm_response(
"""
if executor_context is not None:
if not _setup_before_llm_call_hooks(executor_context, printer):
raise ValueError("LLM call blocked by before_llm_call hook")
_setup_before_llm_call_hooks(executor_context, printer) # Raises if blocked
messages = executor_context.messages
try:
@@ -314,8 +314,7 @@ async def aget_llm_response(
ValueError: If the response is None or empty.
"""
if executor_context is not None:
if not _setup_before_llm_call_hooks(executor_context, printer):
raise ValueError("LLM call blocked by before_llm_call hook")
_setup_before_llm_call_hooks(executor_context, printer) # Raises if blocked
messages = executor_context.messages
try:
@@ -461,6 +460,18 @@ def handle_output_parser_exception(
return formatted_answer
def handle_llm_call_blocked_error(
e: LLMCallBlockedError,
messages: list[LLMMessage],
) -> AgentFinish:
messages.append({"role": "user", "content": str(e)})
return AgentFinish(
thought="",
output=str(e),
text=str(e),
)
def is_context_length_exceeded(exception: Exception) -> bool:
"""Check if the exception is due to context length exceeding.
@@ -728,15 +739,15 @@ def load_agent_from_repository(from_repository: str) -> dict[str, Any]:
def _setup_before_llm_call_hooks(
executor_context: CrewAgentExecutor | LiteAgent | None, printer: Printer
) -> bool:
) -> None:
"""Setup and invoke before_llm_call hooks for the executor context.
Args:
executor_context: The executor context to setup the hooks for.
printer: Printer instance for error logging.
Returns:
True if LLM execution should proceed, False if blocked by a hook.
Raises:
LLMCallBlockedError: If any hook returns False to block the LLM call.
"""
if executor_context and executor_context.before_llm_call_hooks:
from crewai.hooks.llm_hooks import LLMCallHookContext
@@ -752,7 +763,11 @@ def _setup_before_llm_call_hooks(
content="LLM call blocked by before_llm_call hook",
color="yellow",
)
return False
raise LLMCallBlockedError(
"LLM call blocked by before_llm_call hook"
)
except LLMCallBlockedError:
raise
except Exception as e:
printer.print(
content=f"Error in before_llm_call hook: {e}",
@@ -773,8 +788,6 @@ def _setup_before_llm_call_hooks(
else:
executor_context.messages = []
return True
def _setup_after_llm_call_hooks(
executor_context: CrewAgentExecutor | LiteAgent | None,

View File

@@ -30,4 +30,3 @@ NOT_SPECIFIED: Final[
"allows us to distinguish between 'not passed at all' and 'explicitly passed None' or '[]'.",
]
] = _NotSpecified()
CREWAI_BASE_URL: Final[str] = "https://app.crewai.com"

View File

@@ -1,7 +1,7 @@
import os
import unittest
from unittest.mock import ANY, MagicMock, patch
from crewai.cli.constants import DEFAULT_CREWAI_ENTERPRISE_URL
from crewai.cli.plus_api import PlusAPI
@@ -35,7 +35,7 @@ class TestPlusAPI(unittest.TestCase):
):
mock_make_request.assert_called_once_with(
method,
f"{DEFAULT_CREWAI_ENTERPRISE_URL}{endpoint}",
f"{os.getenv('CREWAI_PLUS_URL')}{endpoint}",
headers={
"Authorization": ANY,
"Content-Type": ANY,
@@ -53,7 +53,7 @@ class TestPlusAPI(unittest.TestCase):
):
mock_settings = MagicMock()
mock_settings.org_uuid = self.org_uuid
mock_settings.enterprise_base_url = DEFAULT_CREWAI_ENTERPRISE_URL
mock_settings.enterprise_base_url = os.getenv('CREWAI_PLUS_URL')
mock_settings_class.return_value = mock_settings
# re-initialize Client
self.api = PlusAPI(self.api_key)
@@ -84,7 +84,7 @@ class TestPlusAPI(unittest.TestCase):
def test_get_agent_with_org_uuid(self, mock_make_request, mock_settings_class):
mock_settings = MagicMock()
mock_settings.org_uuid = self.org_uuid
mock_settings.enterprise_base_url = DEFAULT_CREWAI_ENTERPRISE_URL
mock_settings.enterprise_base_url = os.getenv('CREWAI_PLUS_URL')
mock_settings_class.return_value = mock_settings
# re-initialize Client
self.api = PlusAPI(self.api_key)
@@ -115,7 +115,7 @@ class TestPlusAPI(unittest.TestCase):
def test_get_tool_with_org_uuid(self, mock_make_request, mock_settings_class):
mock_settings = MagicMock()
mock_settings.org_uuid = self.org_uuid
mock_settings.enterprise_base_url = DEFAULT_CREWAI_ENTERPRISE_URL
mock_settings.enterprise_base_url = os.getenv('CREWAI_PLUS_URL')
mock_settings_class.return_value = mock_settings
# re-initialize Client
self.api = PlusAPI(self.api_key)
@@ -163,7 +163,7 @@ class TestPlusAPI(unittest.TestCase):
def test_publish_tool_with_org_uuid(self, mock_make_request, mock_settings_class):
mock_settings = MagicMock()
mock_settings.org_uuid = self.org_uuid
mock_settings.enterprise_base_url = DEFAULT_CREWAI_ENTERPRISE_URL
mock_settings.enterprise_base_url = os.getenv('CREWAI_PLUS_URL')
mock_settings_class.return_value = mock_settings
# re-initialize Client
self.api = PlusAPI(self.api_key)
@@ -320,6 +320,7 @@ class TestPlusAPI(unittest.TestCase):
)
@patch("crewai.cli.plus_api.Settings")
@patch.dict(os.environ, {"CREWAI_PLUS_URL": ""})
def test_custom_base_url(self, mock_settings_class):
mock_settings = MagicMock()
mock_settings.enterprise_base_url = "https://custom-url.com/api"
@@ -329,3 +330,11 @@ class TestPlusAPI(unittest.TestCase):
custom_api.base_url,
"https://custom-url.com/api",
)
@patch.dict(os.environ, {"CREWAI_PLUS_URL": "https://custom-url-from-env.com"})
def test_custom_base_url_from_env(self):
custom_api = PlusAPI("test_key")
self.assertEqual(
custom_api.base_url,
"https://custom-url-from-env.com",
)

View File

@@ -4,7 +4,12 @@ from __future__ import annotations
from unittest.mock import Mock
from crewai.hooks import clear_all_llm_call_hooks, unregister_after_llm_call_hook, unregister_before_llm_call_hook
from crewai.hooks import (
LLMCallBlockedError,
clear_all_llm_call_hooks,
unregister_after_llm_call_hook,
unregister_before_llm_call_hook,
)
import pytest
from crewai.hooks.llm_hooks import (
@@ -87,6 +92,86 @@ class TestLLMCallHookContext:
assert new_message in mock_executor.messages
assert len(mock_executor.messages) == 2
def test_before_hook_returning_false_gracefully_finishes(self) -> None:
"""Test that when before_llm_call hook returns False, agent gracefully finishes."""
from crewai import Agent, Crew, Task
hook_called = {"before": False}
def blocking_hook(context: LLMCallHookContext) -> bool:
"""Hook that blocks all LLM calls."""
hook_called["before"] = True
return False
register_before_llm_call_hook(blocking_hook)
try:
agent = Agent(
role="Test Agent",
goal="Answer questions",
backstory="You are a test agent",
verbose=True,
)
task = Task(
description="Say hello",
expected_output="A greeting",
agent=agent,
)
with pytest.raises(LLMCallBlockedError):
crew = Crew(agents=[agent], tasks=[task], verbose=True)
crew.kickoff()
finally:
unregister_before_llm_call_hook(blocking_hook)
def test_direct_llm_call_raises_blocked_error_when_hook_returns_false(self) -> None:
"""Test that direct LLM.call() raises LLMCallBlockedError when hook returns False."""
from crewai.hooks import LLMCallBlockedError
from crewai.llm import LLM
hook_called = {"before": False}
def blocking_hook(context: LLMCallHookContext) -> bool:
"""Hook that blocks all LLM calls."""
hook_called["before"] = True
return False
register_before_llm_call_hook(blocking_hook)
try:
llm = LLM(model="gpt-4o-mini")
with pytest.raises(LLMCallBlockedError) as exc_info:
llm.call([{"role": "user", "content": "Say hello"}])
assert hook_called["before"] is True, "Before hook should have been called"
assert "blocked" in str(exc_info.value).lower()
finally:
unregister_before_llm_call_hook(blocking_hook)
def test_raises_with_llm_call_blocked_exception(self) -> None:
"""Test that the LLM call raises an exception when the hook raises an exception."""
from crewai.hooks import LLMCallBlockedError
from crewai.llm import LLM
def blocking_hook(context: LLMCallHookContext) -> bool:
raise LLMCallBlockedError("llm call blocked")
register_before_llm_call_hook(blocking_hook)
try:
llm = LLM(model="gpt-4o-mini")
with pytest.raises(LLMCallBlockedError) as exc_info:
llm.call([{"role": "user", "content": "Say hello"}])
assert "blocked" in str(exc_info.value).lower()
finally:
unregister_before_llm_call_hook(blocking_hook)
class TestBeforeLLMCallHooks:
"""Test before_llm_call hook registration and execution."""

View File

@@ -1,3 +1,3 @@
"""CrewAI development tools."""
__version__ = "1.7.1"
__version__ = "1.7.2"