Compare commits

..

3 Commits

Author SHA1 Message Date
Rip&Tear
323334dac6 fix: clear CodeQL incomplete URL substring sanitization alerts (#6804)
Some checks are pending
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (python) (push) Waiting to run
Vulnerability Scan / Detect changes (push) Waiting to run
Vulnerability Scan / pip-audit (push) Blocked by required conditions
* fix: clear CodeQL incomplete URL substring sanitization alerts

Replace hostname substring checks with urlparse hostname matching in
RAG DataType classification, and assert the full mocked Stagehand
navigate result instead of searching for a URL substring.

Co-authored-by: Rip&Tear <theCyberTech@users.noreply.github.com>

* test: call DataTypes.from_content in GitHub hostname tests

from_content lives on DataTypes, not the DataType enum.

Co-authored-by: Rip&Tear <theCyberTech@users.noreply.github.com>

* test: harden tool-call streaming emit mock against instance shadowing

CI failed when class-level CrewAIEventsBus.emit patches were shadowed by
the singleton instance. Patch both the class and crewai_event_bus.emit,
and read events from kwargs/args explicitly.

Co-authored-by: Rip&Tear <theCyberTech@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Rip&Tear <theCyberTech@users.noreply.github.com>
2026-08-05 13:28:47 +08:00
Vidit Ostwal
7accafbaf4 chore(ci): group uv patch/minor Dependabot updates (#6807)
Some checks failed
Build uv cache / build-cache (3.10) (push) Waiting to run
Build uv cache / build-cache (3.11) (push) Waiting to run
Build uv cache / build-cache (3.12) (push) Waiting to run
Build uv cache / build-cache (3.13) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Vulnerability Scan / Detect changes (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Add a patch-minor-updates group for routine version bumps while keeping
the existing security-updates grouping. Ignore semver-major updates so
breaking upgrades stay manual.
2026-08-04 23:04:20 +05:30
Lucas Gomide
9d659a644b feat: track interception-hook dispatches in telemetry (#6805)
`HookDispatchedEvent` was already emitted from the dispatcher but never
landed in Feature Usage. Wire it through `hook_dispatched_span` so hook
adoption and abort outcomes (e.g. policy checks) show up in the same
ClickHouse aggregation as other features.
2026-08-04 13:13:24 -04:00
12 changed files with 459 additions and 14 deletions

View File

@@ -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

View File

@@ -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

View 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
)

View File

@@ -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(

View File

@@ -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()

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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."""

View File

@@ -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

View File

@@ -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",
)