mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-08-05 05:51:45 +00:00
Compare commits
3 Commits
codex/llm-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
323334dac6 | ||
|
|
7accafbaf4 | ||
|
|
9d659a644b |
18
.github/dependabot.yml
vendored
18
.github/dependabot.yml
vendored
@@ -1,6 +1,3 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
@@ -8,9 +5,22 @@ updates:
|
||||
- package-ecosystem: uv
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
interval: weekly
|
||||
day: monday
|
||||
open-pull-requests-limit: 10
|
||||
groups:
|
||||
security-updates:
|
||||
applies-to: security-updates
|
||||
patterns:
|
||||
- "*"
|
||||
patch-minor-updates:
|
||||
applies-to: version-updates
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- patch
|
||||
- minor
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types:
|
||||
- version-update:semver-major
|
||||
|
||||
@@ -135,7 +135,8 @@ class DataTypes:
|
||||
|
||||
if "docs" in url.netloc or ("docs" in url.path and url.scheme != "file"):
|
||||
return DataType.DOCS_SITE
|
||||
if "github.com" in url.netloc:
|
||||
hostname = (url.hostname or "").lower()
|
||||
if hostname == "github.com" or hostname.endswith(".github.com"):
|
||||
return DataType.GITHUB
|
||||
|
||||
return DataType.WEBSITE
|
||||
|
||||
32
lib/crewai-tools/tests/rag/test_data_types.py
Normal file
32
lib/crewai-tools/tests/rag/test_data_types.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Tests for DataTypes content classification."""
|
||||
|
||||
from crewai_tools.rag.data_types import DataType, DataTypes
|
||||
|
||||
|
||||
class TestDataTypesFromContentGitHub:
|
||||
"""GitHub URL detection must use hostname matching, not substrings."""
|
||||
|
||||
def test_github_com_url(self) -> None:
|
||||
assert (
|
||||
DataTypes.from_content("https://github.com/crewai/crewai")
|
||||
== DataType.GITHUB
|
||||
)
|
||||
|
||||
def test_github_subdomain_url(self) -> None:
|
||||
assert (
|
||||
DataTypes.from_content("https://gist.github.com/user/abc")
|
||||
== DataType.GITHUB
|
||||
)
|
||||
|
||||
def test_spoofed_github_hostname_is_website(self) -> None:
|
||||
# Substring checks like `"github.com" in netloc` would misclassify this.
|
||||
assert (
|
||||
DataTypes.from_content("https://github.com.evil.example/crewai")
|
||||
== DataType.WEBSITE
|
||||
)
|
||||
|
||||
def test_github_in_path_is_not_github(self) -> None:
|
||||
assert (
|
||||
DataTypes.from_content("https://example.com/github.com/repo")
|
||||
== DataType.WEBSITE
|
||||
)
|
||||
@@ -163,8 +163,14 @@ def test_navigate_command(mock_run, stagehand_tool):
|
||||
command_type="navigate",
|
||||
)
|
||||
|
||||
# Assertions
|
||||
assert "https://example.com" in result
|
||||
# Assertions — compare the full mocked result (avoid URL substring checks)
|
||||
assert result == "Successfully navigated to https://example.com"
|
||||
mock_run.assert_called_once_with(
|
||||
stagehand_tool,
|
||||
instruction="Go to example.com",
|
||||
url="https://example.com",
|
||||
command_type="navigate",
|
||||
)
|
||||
|
||||
|
||||
@patch(
|
||||
|
||||
@@ -54,6 +54,7 @@ from crewai.events.types.flow_events import (
|
||||
MethodExecutionPausedEvent,
|
||||
MethodExecutionStartedEvent,
|
||||
)
|
||||
from crewai.events.types.hook_events import HookDispatchedEvent
|
||||
from crewai.events.types.knowledge_events import (
|
||||
KnowledgeQueryCompletedEvent,
|
||||
KnowledgeQueryFailedEvent,
|
||||
@@ -875,5 +876,12 @@ class EventListener(BaseEventListener):
|
||||
if has_hooks:
|
||||
self._telemetry.feature_usage_span("hooks:registered")
|
||||
|
||||
@crewai_event_bus.on(HookDispatchedEvent)
|
||||
def on_hook_dispatched(_: Any, event: HookDispatchedEvent) -> None:
|
||||
self._telemetry.hook_dispatched_span(
|
||||
interception_point=event.interception_point,
|
||||
outcome=event.outcome,
|
||||
)
|
||||
|
||||
|
||||
event_listener = EventListener()
|
||||
|
||||
@@ -438,6 +438,17 @@ class BaseLLM(BaseModel, ABC):
|
||||
"""
|
||||
return DEFAULT_SUPPORTS_STOP_WORDS
|
||||
|
||||
def _supports_stop_words_implementation(self) -> bool:
|
||||
"""Check if stop words are configured for this LLM instance.
|
||||
|
||||
Native providers can override supports_stop_words() to return this value
|
||||
to ensure consistent behavior based on whether stop words are actually configured.
|
||||
|
||||
Returns:
|
||||
True if stop words are configured and can be applied
|
||||
"""
|
||||
return bool(self.stop_sequences)
|
||||
|
||||
def _apply_stop_words(self, content: str) -> str:
|
||||
"""Apply stop words to truncate response content.
|
||||
|
||||
|
||||
@@ -1385,6 +1385,120 @@ class AnthropicCompletion(BaseLLM):
|
||||
from_agent=from_agent,
|
||||
)
|
||||
|
||||
# TODO: we drop this
|
||||
def _handle_tool_use_conversation(
|
||||
self,
|
||||
initial_response: Message | BetaMessage,
|
||||
tool_uses: list[_AnthropicToolUseBlock],
|
||||
params: dict[str, Any],
|
||||
available_functions: dict[str, Any],
|
||||
from_task: Any | None = None,
|
||||
from_agent: Any | None = None,
|
||||
) -> str:
|
||||
"""Handle the complete tool use conversation flow.
|
||||
|
||||
This implements the proper Anthropic tool use pattern:
|
||||
1. Claude requests tool use
|
||||
2. We execute the tools
|
||||
3. We send tool results back to Claude
|
||||
4. Claude processes results and generates final response
|
||||
"""
|
||||
tool_results = self._execute_tools_and_collect_results(
|
||||
tool_uses, available_functions, from_task, from_agent
|
||||
)
|
||||
|
||||
follow_up_params = params.copy()
|
||||
|
||||
assistant_content: list[
|
||||
ThinkingBlock | ToolUseBlock | TextBlock | dict[str, Any]
|
||||
] = []
|
||||
for block in initial_response.content:
|
||||
thinking_block = self._extract_thinking_block(block)
|
||||
if thinking_block:
|
||||
assistant_content.append(thinking_block)
|
||||
elif _is_tool_use_block(block):
|
||||
assistant_content.append(
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": _tool_use_id(block),
|
||||
"name": _tool_use_name(block),
|
||||
"input": _tool_use_input(block),
|
||||
}
|
||||
)
|
||||
elif hasattr(block, "text"):
|
||||
assistant_content.append({"type": "text", "text": block.text})
|
||||
|
||||
assistant_message = {"role": "assistant", "content": assistant_content}
|
||||
|
||||
user_message = {"role": "user", "content": tool_results}
|
||||
|
||||
follow_up_params["messages"] = params["messages"] + [
|
||||
assistant_message,
|
||||
user_message,
|
||||
]
|
||||
|
||||
try:
|
||||
final_response: Message = self._get_sync_client().messages.create(
|
||||
**follow_up_params
|
||||
)
|
||||
|
||||
follow_up_usage = self._extract_anthropic_token_usage(final_response)
|
||||
self._track_token_usage_internal(follow_up_usage)
|
||||
|
||||
final_content = ""
|
||||
thinking_blocks: list[ThinkingBlock] = []
|
||||
|
||||
if final_response.content:
|
||||
for content_block in final_response.content:
|
||||
if hasattr(content_block, "text"):
|
||||
final_content += content_block.text
|
||||
else:
|
||||
thinking_block = self._extract_thinking_block(content_block)
|
||||
if thinking_block:
|
||||
thinking_blocks.append(cast(ThinkingBlock, thinking_block))
|
||||
|
||||
if thinking_blocks:
|
||||
self._previous_thinking_blocks = thinking_blocks
|
||||
|
||||
final_content = self._apply_stop_words(final_content)
|
||||
|
||||
finish_reason, final_response_id = self._extract_finish_reason_and_id(
|
||||
final_response
|
||||
)
|
||||
|
||||
self._emit_call_completed_event(
|
||||
response=final_content,
|
||||
call_type=LLMCallType.LLM_CALL,
|
||||
from_task=from_task,
|
||||
from_agent=from_agent,
|
||||
messages=follow_up_params["messages"],
|
||||
usage=follow_up_usage,
|
||||
finish_reason=finish_reason,
|
||||
response_id=final_response_id,
|
||||
)
|
||||
|
||||
total_usage = {
|
||||
"input_tokens": follow_up_usage.get("input_tokens", 0),
|
||||
"output_tokens": follow_up_usage.get("output_tokens", 0),
|
||||
"total_tokens": follow_up_usage.get("total_tokens", 0),
|
||||
}
|
||||
|
||||
if total_usage.get("total_tokens", 0) > 0:
|
||||
logging.info(f"Anthropic API tool conversation usage: {total_usage}")
|
||||
|
||||
return final_content
|
||||
|
||||
except Exception as e:
|
||||
if is_context_length_exceeded(e):
|
||||
logging.error(f"Context window exceeded in tool follow-up: {e}")
|
||||
raise LLMContextLengthExceededError(str(e)) from e
|
||||
|
||||
logging.error(f"Tool follow-up conversation failed: {e}")
|
||||
# Fallback to first tool result when follow-up fails
|
||||
if tool_results:
|
||||
return cast(str, tool_results[0]["content"])
|
||||
raise e
|
||||
|
||||
async def _ahandle_completion(
|
||||
self,
|
||||
params: dict[str, Any],
|
||||
@@ -1716,6 +1830,90 @@ class AnthropicCompletion(BaseLLM):
|
||||
|
||||
return full_response
|
||||
|
||||
async def _ahandle_tool_use_conversation(
|
||||
self,
|
||||
initial_response: Message | BetaMessage,
|
||||
tool_uses: list[_AnthropicToolUseBlock],
|
||||
params: dict[str, Any],
|
||||
available_functions: dict[str, Any],
|
||||
from_task: Any | None = None,
|
||||
from_agent: Any | None = None,
|
||||
) -> str:
|
||||
"""Handle the complete async tool use conversation flow.
|
||||
|
||||
This implements the proper Anthropic tool use pattern:
|
||||
1. Claude requests tool use
|
||||
2. We execute the tools
|
||||
3. We send tool results back to Claude
|
||||
4. Claude processes results and generates final response
|
||||
"""
|
||||
tool_results = self._execute_tools_and_collect_results(
|
||||
tool_uses, available_functions, from_task, from_agent
|
||||
)
|
||||
|
||||
follow_up_params = params.copy()
|
||||
|
||||
assistant_message = {"role": "assistant", "content": initial_response.content}
|
||||
|
||||
user_message = {"role": "user", "content": tool_results}
|
||||
|
||||
follow_up_params["messages"] = params["messages"] + [
|
||||
assistant_message,
|
||||
user_message,
|
||||
]
|
||||
|
||||
try:
|
||||
final_response: Message = await self._get_async_client().messages.create(
|
||||
**follow_up_params
|
||||
)
|
||||
|
||||
follow_up_usage = self._extract_anthropic_token_usage(final_response)
|
||||
self._track_token_usage_internal(follow_up_usage)
|
||||
|
||||
final_content = ""
|
||||
if final_response.content:
|
||||
for content_block in final_response.content:
|
||||
if hasattr(content_block, "text"):
|
||||
final_content += content_block.text
|
||||
|
||||
final_content = self._apply_stop_words(final_content)
|
||||
|
||||
finish_reason, final_response_id = self._extract_finish_reason_and_id(
|
||||
final_response
|
||||
)
|
||||
|
||||
self._emit_call_completed_event(
|
||||
response=final_content,
|
||||
call_type=LLMCallType.LLM_CALL,
|
||||
from_task=from_task,
|
||||
from_agent=from_agent,
|
||||
messages=follow_up_params["messages"],
|
||||
usage=follow_up_usage,
|
||||
finish_reason=finish_reason,
|
||||
response_id=final_response_id,
|
||||
)
|
||||
|
||||
total_usage = {
|
||||
"input_tokens": follow_up_usage.get("input_tokens", 0),
|
||||
"output_tokens": follow_up_usage.get("output_tokens", 0),
|
||||
"total_tokens": follow_up_usage.get("total_tokens", 0),
|
||||
}
|
||||
|
||||
if total_usage.get("total_tokens", 0) > 0:
|
||||
logging.info(f"Anthropic API tool conversation usage: {total_usage}")
|
||||
|
||||
return final_content
|
||||
|
||||
except Exception as e:
|
||||
if is_context_length_exceeded(e):
|
||||
logging.error(f"Context window exceeded in tool follow-up: {e}")
|
||||
raise LLMContextLengthExceededError(str(e)) from e
|
||||
|
||||
logging.error(f"Tool follow-up conversation failed: {e}")
|
||||
if tool_results:
|
||||
return cast(str, tool_results[0]["content"])
|
||||
raise e
|
||||
|
||||
def supports_function_calling(self) -> bool:
|
||||
"""Check if the model supports function calling."""
|
||||
return self.supports_tools
|
||||
|
||||
@@ -2146,6 +2146,17 @@ class BedrockCompletion(BaseLLM):
|
||||
)
|
||||
return any(model_lower.startswith(m) for m in vision_models)
|
||||
|
||||
def _is_nova_model(self) -> bool:
|
||||
"""Check if the model is an Amazon Nova model.
|
||||
|
||||
Only Nova models support S3 links for multimedia.
|
||||
|
||||
Returns:
|
||||
True if the model is a Nova model.
|
||||
"""
|
||||
model_lower = self.model.lower()
|
||||
return "amazon.nova-" in model_lower
|
||||
|
||||
def get_file_uploader(self) -> Any:
|
||||
"""Get a Bedrock S3 file uploader using this LLM's AWS credentials.
|
||||
|
||||
@@ -2174,6 +2185,49 @@ class BedrockCompletion(BaseLLM):
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
def _get_document_format(self, content_type: str) -> str | None:
|
||||
"""Map content type to Bedrock document format.
|
||||
|
||||
Args:
|
||||
content_type: MIME type of the document.
|
||||
|
||||
Returns:
|
||||
Bedrock format string or None if unsupported.
|
||||
"""
|
||||
format_map = {
|
||||
"application/pdf": "pdf",
|
||||
"text/csv": "csv",
|
||||
"text/plain": "txt",
|
||||
"text/markdown": "md",
|
||||
"text/html": "html",
|
||||
"application/msword": "doc",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
|
||||
"application/vnd.ms-excel": "xls",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
|
||||
}
|
||||
return format_map.get(content_type)
|
||||
|
||||
def _get_video_format(self, content_type: str) -> str | None:
|
||||
"""Map content type to Bedrock video format.
|
||||
|
||||
Args:
|
||||
content_type: MIME type of the video.
|
||||
|
||||
Returns:
|
||||
Bedrock format string or None if unsupported.
|
||||
"""
|
||||
format_map = {
|
||||
"video/mp4": "mp4",
|
||||
"video/quicktime": "mov",
|
||||
"video/x-matroska": "mkv",
|
||||
"video/webm": "webm",
|
||||
"video/x-flv": "flv",
|
||||
"video/mpeg": "mpeg",
|
||||
"video/x-ms-wmv": "wmv",
|
||||
"video/3gpp": "three_gp",
|
||||
}
|
||||
return format_map.get(content_type)
|
||||
|
||||
def format_text_content(self, text: str) -> dict[str, Any]:
|
||||
"""Format text as a Bedrock content block.
|
||||
|
||||
|
||||
@@ -1148,7 +1148,8 @@ class Telemetry:
|
||||
|
||||
Args:
|
||||
feature: Feature identifier, e.g. "planning:creation",
|
||||
"mcp:connection", "a2a:delegation".
|
||||
"mcp:connection", "a2a:delegation",
|
||||
"hooks:pre_tool_call", "hooks:aborted".
|
||||
"""
|
||||
|
||||
def _operation() -> None:
|
||||
@@ -1160,6 +1161,21 @@ class Telemetry:
|
||||
|
||||
self._safe_telemetry_operation(_operation)
|
||||
|
||||
def hook_dispatched_span(
|
||||
self,
|
||||
interception_point: str,
|
||||
outcome: str,
|
||||
) -> None:
|
||||
"""Records an interception-hook dispatch via Feature Usage.
|
||||
|
||||
Emits ``hooks:<point>`` on every dispatch, plus ``hooks:aborted`` when
|
||||
a hook aborted the operation (e.g. a policy check). No reasons,
|
||||
payloads, or other user content are recorded.
|
||||
"""
|
||||
self.feature_usage_span(f"hooks:{interception_point}")
|
||||
if outcome == "aborted":
|
||||
self.feature_usage_span("hooks:aborted")
|
||||
|
||||
def coding_agent_span(self) -> None:
|
||||
"""Records which AI coding assistant (if any) is running this process.
|
||||
|
||||
|
||||
@@ -1576,6 +1576,30 @@ def test_anthropic_dict_tool_use_blocks_execute_available_function():
|
||||
assert result == "found CrewAI"
|
||||
|
||||
|
||||
def test_anthropic_dict_tool_use_blocks_work_in_follow_up_conversation():
|
||||
from crewai.llms.providers.anthropic.completion import AnthropicCompletion
|
||||
|
||||
llm = AnthropicCompletion(model="claude-fable-5")
|
||||
initial_response = _dict_tool_use_response()
|
||||
final_response = MagicMock()
|
||||
final_response.content = [types.SimpleNamespace(text="Final answer")]
|
||||
final_response.usage = MagicMock(input_tokens=4, output_tokens=3)
|
||||
final_response.stop_reason = "end_turn"
|
||||
final_response.id = "msg_final"
|
||||
mock_client = MagicMock()
|
||||
mock_client.messages.create.return_value = final_response
|
||||
llm._client = mock_client
|
||||
|
||||
result = llm._handle_tool_use_conversation(
|
||||
initial_response,
|
||||
initial_response.content,
|
||||
params={"messages": []},
|
||||
available_functions={"search_web": lambda query: f"found {query}"},
|
||||
)
|
||||
|
||||
assert result == "Final answer"
|
||||
|
||||
|
||||
@pytest.mark.vcr()
|
||||
def test_tool_search_discovers_and_calls_tool():
|
||||
"""Tool search should discover the right tool and return a tool_use block."""
|
||||
|
||||
@@ -38,18 +38,33 @@ def get_temperature_tool_schema() -> dict[str, Any]:
|
||||
|
||||
@pytest.fixture
|
||||
def mock_emit() -> MagicMock:
|
||||
"""Mock the event bus emit function."""
|
||||
from crewai.events.event_bus import CrewAIEventsBus
|
||||
"""Mock the singleton event bus emit used by LLM providers.
|
||||
|
||||
with patch.object(CrewAIEventsBus, "emit") as mock:
|
||||
yield mock
|
||||
Patch the singleton instance (not only the class) so a leftover
|
||||
instance-level ``emit`` from other tests cannot shadow the mock.
|
||||
"""
|
||||
from crewai.events.event_bus import CrewAIEventsBus, crewai_event_bus
|
||||
|
||||
with (
|
||||
patch.object(CrewAIEventsBus, "emit") as class_mock,
|
||||
patch.object(crewai_event_bus, "emit", new=class_mock),
|
||||
):
|
||||
yield class_mock
|
||||
|
||||
|
||||
def _event_from_emit_call(call: Any) -> Any:
|
||||
"""Return the event argument from an emit mock call."""
|
||||
event = call.kwargs.get("event")
|
||||
if event is None and len(call.args) >= 2:
|
||||
event = call.args[1]
|
||||
return event
|
||||
|
||||
|
||||
def get_tool_call_events(mock_emit: MagicMock) -> list[LLMStreamChunkEvent]:
|
||||
"""Extract tool call streaming events from mock emit calls."""
|
||||
tool_call_events = []
|
||||
for call in mock_emit.call_args_list:
|
||||
event = call[1].get("event") if len(call) > 1 else None
|
||||
event = _event_from_emit_call(call)
|
||||
if isinstance(event, LLMStreamChunkEvent) and event.call_type == LLMCallType.TOOL_CALL:
|
||||
tool_call_events.append(event)
|
||||
return tool_call_events
|
||||
@@ -59,7 +74,7 @@ def get_all_stream_events(mock_emit: MagicMock) -> list[LLMStreamChunkEvent]:
|
||||
"""Extract all streaming events from mock emit calls."""
|
||||
stream_events = []
|
||||
for call in mock_emit.call_args_list:
|
||||
event = call[1].get("event") if len(call) > 1 else None
|
||||
event = _event_from_emit_call(call)
|
||||
if isinstance(event, LLMStreamChunkEvent):
|
||||
stream_events.append(event)
|
||||
return stream_events
|
||||
|
||||
@@ -230,3 +230,73 @@ def test_no_signal_handler_traceback_in_non_main_thread():
|
||||
mock_holder["logger"].debug.assert_any_call(
|
||||
"Skipping signal handler registration: not running in main thread"
|
||||
)
|
||||
|
||||
|
||||
def test_hook_dispatched_span_counts_point_usage():
|
||||
with (
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"CREWAI_DISABLE_TELEMETRY": "false",
|
||||
"CREWAI_DISABLE_TRACKING": "false",
|
||||
"OTEL_SDK_DISABLED": "false",
|
||||
},
|
||||
),
|
||||
patch("crewai.telemetry.telemetry.TracerProvider"),
|
||||
):
|
||||
telemetry = Telemetry()
|
||||
with patch.object(telemetry, "feature_usage_span") as feature_usage_span:
|
||||
telemetry.hook_dispatched_span("pre_tool_call", "proceeded")
|
||||
|
||||
feature_usage_span.assert_called_once_with("hooks:pre_tool_call")
|
||||
|
||||
|
||||
def test_hook_dispatched_span_counts_aborts():
|
||||
with (
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"CREWAI_DISABLE_TELEMETRY": "false",
|
||||
"CREWAI_DISABLE_TRACKING": "false",
|
||||
"OTEL_SDK_DISABLED": "false",
|
||||
},
|
||||
),
|
||||
patch("crewai.telemetry.telemetry.TracerProvider"),
|
||||
):
|
||||
telemetry = Telemetry()
|
||||
with patch.object(telemetry, "feature_usage_span") as feature_usage_span:
|
||||
telemetry.hook_dispatched_span("pre_tool_call", "aborted")
|
||||
|
||||
feature_usage_span.assert_any_call("hooks:pre_tool_call")
|
||||
feature_usage_span.assert_any_call("hooks:aborted")
|
||||
assert feature_usage_span.call_count == 2
|
||||
|
||||
|
||||
def test_event_listener_tracks_hook_dispatched_events():
|
||||
from crewai.events.event_bus import crewai_event_bus
|
||||
from crewai.events.event_listener import event_listener
|
||||
from crewai.events.types.hook_events import HookDispatchedEvent
|
||||
|
||||
with (
|
||||
crewai_event_bus.scoped_handlers(),
|
||||
patch.object(
|
||||
event_listener._telemetry,
|
||||
"hook_dispatched_span",
|
||||
) as hook_dispatched_span,
|
||||
):
|
||||
event_listener.setup_listeners(crewai_event_bus)
|
||||
crewai_event_bus.emit(
|
||||
"test",
|
||||
HookDispatchedEvent(
|
||||
interception_point="pre_tool_call",
|
||||
outcome="aborted",
|
||||
hook_count=1,
|
||||
duration_ms=1.5,
|
||||
),
|
||||
)
|
||||
crewai_event_bus.flush()
|
||||
|
||||
hook_dispatched_span.assert_called_once_with(
|
||||
interception_point="pre_tool_call",
|
||||
outcome="aborted",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user